1# -*- coding: utf-8 -*-
2import math
3import calendar
4from datetime import date, datetime, time
5import pytz
6from dateutil.relativedelta import relativedelta
7
8from . import ustr
9from .func import lazy
10
11def get_month(date):
12    ''' Compute the month dates range on which the 'date' parameter belongs to.
13
14    :param date: A datetime.datetime or datetime.date object.
15    :return: A tuple (date_from, date_to) having the same object type as the 'date' parameter.
16    '''
17    date_from = type(date)(date.year, date.month, 1)
18    date_to = type(date)(date.year, date.month, calendar.monthrange(date.year, date.month)[1])
19    return date_from, date_to
20
21
22def get_quarter_number(date):
23    ''' Get the number of the quarter on which the 'date' parameter belongs to.
24
25    :param date: A datetime.datetime or datetime.date object.
26    :return: A [1-4] integer.
27    '''
28    return math.ceil(date.month / 3)
29
30
31def get_quarter(date):
32    ''' Compute the quarter dates range on which the 'date' parameter belongs to.
33
34    :param date: A datetime.datetime or datetime.date object.
35    :return: A tuple (date_from, date_to) having the same object type as the 'date' parameter.
36    '''
37    quarter_number = get_quarter_number(date)
38    month_from = ((quarter_number - 1) * 3) + 1
39    date_from = type(date)(date.year, month_from, 1)
40    date_to = (date_from + relativedelta(months=2))
41    date_to = date_to.replace(day=calendar.monthrange(date_to.year, date_to.month)[1])
42    return date_from, date_to
43
44
45def get_fiscal_year(date, day=31, month=12):
46    ''' Compute the fiscal year dates range on which the 'date' parameter belongs to.
47    A fiscal year is the period used by governments for accounting purposes and vary between countries.
48
49    By default, calling this method with only one parameter gives the calendar year because the ending date of the
50    fiscal year is set to the YYYY-12-31.
51
52    :param date:    A datetime.datetime or datetime.date object.
53    :param day:     The day of month the fiscal year ends.
54    :param month:   The month of year the fiscal year ends.
55    :return: A tuple (date_from, date_to) having the same object type as the 'date' parameter.
56    '''
57    max_day = calendar.monthrange(date.year, month)[1]
58    date_to = type(date)(date.year, month, min(day, max_day))
59
60    # Force at 29 February instead of 28 in case of leap year.
61    if date_to.month == 2 and date_to.day == 28 and max_day == 29:
62        date_to = type(date)(date.year, 2, 29)
63
64    if date <= date_to:
65        date_from = date_to - relativedelta(years=1)
66        max_day = calendar.monthrange(date_from.year, date_from.month)[1]
67
68        # Force at 29 February instead of 28 in case of leap year.
69        if date_from.month == 2 and date_from.day == 28 and max_day == 29:
70            date_from = type(date)(date_from.year, 2, 29)
71
72        date_from += relativedelta(days=1)
73    else:
74        date_from = date_to + relativedelta(days=1)
75        max_day = calendar.monthrange(date_to.year + 1, date_to.month)[1]
76        date_to = type(date)(date.year + 1, month, min(day, max_day))
77
78        # Force at 29 February instead of 28 in case of leap year.
79        if date_to.month == 2 and date_to.day == 28 and max_day == 29:
80            date_to += relativedelta(days=1)
81    return date_from, date_to
82
83
84def get_timedelta(qty, granularity):
85    """
86        Helper to get a `relativedelta` object for the given quantity and interval unit.
87        :param qty: the number of unit to apply on the timedelta to return
88        :param granularity: Type of period in string, can be year, quarter, month, week, day or hour.
89
90    """
91    switch = {
92        'hour': relativedelta(hours=qty),
93        'day': relativedelta(days=qty),
94        'week': relativedelta(weeks=qty),
95        'month': relativedelta(months=qty),
96        'year': relativedelta(years=qty),
97    }
98    return switch[granularity]
99
100
101def start_of(value, granularity):
102    """
103    Get start of a time period from a date or a datetime.
104
105    :param value: initial date or datetime.
106    :param granularity: type of period in string, can be year, quarter, month, week, day or hour.
107    :return: a date/datetime object corresponding to the start of the specified period.
108    """
109    is_datetime = isinstance(value, datetime)
110    if granularity == "year":
111        result = value.replace(month=1, day=1)
112    elif granularity == "quarter":
113        # Q1 = Jan 1st
114        # Q2 = Apr 1st
115        # Q3 = Jul 1st
116        # Q4 = Oct 1st
117        result = get_quarter(value)[0]
118    elif granularity == "month":
119        result = value.replace(day=1)
120    elif granularity == 'week':
121        # `calendar.weekday` uses ISO8601 for start of week reference, this means that
122        # by default MONDAY is the first day of the week and SUNDAY is the last.
123        result = value - relativedelta(days=calendar.weekday(value.year, value.month, value.day))
124    elif granularity == "day":
125        result = value
126    elif granularity == "hour" and is_datetime:
127        return datetime.combine(value, time.min).replace(hour=value.hour)
128    elif is_datetime:
129        raise ValueError(
130            "Granularity must be year, quarter, month, week, day or hour for value %s" % value
131        )
132    else:
133        raise ValueError(
134            "Granularity must be year, quarter, month, week or day for value %s" % value
135        )
136
137    return datetime.combine(result, time.min) if is_datetime else result
138
139
140def end_of(value, granularity):
141    """
142    Get end of a time period from a date or a datetime.
143
144    :param value: initial date or datetime.
145    :param granularity: Type of period in string, can be year, quarter, month, week, day or hour.
146    :return: A date/datetime object corresponding to the start of the specified period.
147    """
148    is_datetime = isinstance(value, datetime)
149    if granularity == "year":
150        result = value.replace(month=12, day=31)
151    elif granularity == "quarter":
152        # Q1 = Mar 31st
153        # Q2 = Jun 30th
154        # Q3 = Sep 30th
155        # Q4 = Dec 31st
156        result = get_quarter(value)[1]
157    elif granularity == "month":
158        result = value + relativedelta(day=1, months=1, days=-1)
159    elif granularity == 'week':
160        # `calendar.weekday` uses ISO8601 for start of week reference, this means that
161        # by default MONDAY is the first day of the week and SUNDAY is the last.
162        result = value + relativedelta(days=6-calendar.weekday(value.year, value.month, value.day))
163    elif granularity == "day":
164        result = value
165    elif granularity == "hour" and is_datetime:
166        return datetime.combine(value, time.max).replace(hour=value.hour)
167    elif is_datetime:
168        raise ValueError(
169            "Granularity must be year, quarter, month, week, day or hour for value %s" % value
170        )
171    else:
172        raise ValueError(
173            "Granularity must be year, quarter, month, week or day for value %s" % value
174        )
175
176    return datetime.combine(result, time.max) if is_datetime else result
177
178
179def add(value, *args, **kwargs):
180    """
181    Return the sum of ``value`` and a :class:`relativedelta`.
182
183    :param value: initial date or datetime.
184    :param args: positional args to pass directly to :class:`relativedelta`.
185    :param kwargs: keyword args to pass directly to :class:`relativedelta`.
186    :return: the resulting date/datetime.
187    """
188    return value + relativedelta(*args, **kwargs)
189
190
191def subtract(value, *args, **kwargs):
192    """
193    Return the difference between ``value`` and a :class:`relativedelta`.
194
195    :param value: initial date or datetime.
196    :param args: positional args to pass directly to :class:`relativedelta`.
197    :param kwargs: keyword args to pass directly to :class:`relativedelta`.
198    :return: the resulting date/datetime.
199    """
200    return value - relativedelta(*args, **kwargs)
201
202def json_default(obj):
203    """
204    Properly serializes date and datetime objects.
205    """
206    from odoo import fields
207    if isinstance(obj, datetime):
208        return fields.Datetime.to_string(obj)
209    if isinstance(obj, date):
210        return fields.Date.to_string(obj)
211    if isinstance(obj, lazy):
212        return obj._value
213    return ustr(obj)
214
215
216def date_range(start, end, step=relativedelta(months=1)):
217    """Date range generator with a step interval.
218
219    :param start datetime: beginning date of the range.
220    :param end datetime: ending date of the range.
221    :param step relativedelta: interval of the range.
222    :return: a range of datetime from start to end.
223    :rtype: Iterator[datetime]
224    """
225
226    are_naive = start.tzinfo is None and end.tzinfo is None
227    are_utc = start.tzinfo == pytz.utc and end.tzinfo == pytz.utc
228
229    # Cases with miscellenous timezone are more complexe because of DST.
230    are_others = start.tzinfo and end.tzinfo and not are_utc
231
232    if are_others:
233        if start.tzinfo.zone != end.tzinfo.zone:
234            raise ValueError("Timezones of start argument and end argument seem inconsistent")
235
236    if not are_naive and not are_utc and not are_others:
237        raise ValueError("Timezones of start argument and end argument mismatch")
238
239    if start > end:
240        raise ValueError("start > end, start date must be before end")
241
242    if start == start + step:
243        raise ValueError("Looks like step is null")
244
245    if start.tzinfo:
246        localize = start.tzinfo.localize
247    else:
248        localize = lambda dt: dt
249
250    dt = start.replace(tzinfo=None)
251    end = end.replace(tzinfo=None)
252    while dt <= end:
253        yield localize(dt)
254        dt = dt + step
255