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