Coverage for adhoc-cicd-odoo-odoo / odoo / orm / fields_temporal.py: 54%

160 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-09 18:05 +0000

1from __future__ import annotations 

2 

3import typing 

4from datetime import date, datetime, time 

5 

6import pytz 

7 

8from odoo.tools import DEFAULT_SERVER_DATE_FORMAT as DATE_FORMAT 

9from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT as DATETIME_FORMAT 

10from odoo.tools import SQL, date_utils 

11 

12from .fields import Field, _logger 

13from .utils import parse_field_expr, READ_GROUP_NUMBER_GRANULARITY 

14 

15if typing.TYPE_CHECKING: 

16 from collections.abc import Callable 

17 from odoo.tools import Query 

18 

19 from .models import BaseModel 

20 

21T = typing.TypeVar("T") 

22 

23DATE_LENGTH = len(date.today().strftime(DATE_FORMAT)) 

24DATETIME_LENGTH = len(datetime.now().strftime(DATETIME_FORMAT)) 

25 

26 

27class BaseDate(Field[T | typing.Literal[False]], typing.Generic[T]): 

28 """ Common field properties for Date and Datetime. """ 

29 

30 start_of = staticmethod(date_utils.start_of) 

31 end_of = staticmethod(date_utils.end_of) 

32 add = staticmethod(date_utils.add) 

33 subtract = staticmethod(date_utils.subtract) 

34 

35 def expression_getter(self, field_expr): 

36 _fname, property_name = parse_field_expr(field_expr) 

37 if not property_name: 37 ↛ 40line 37 didn't jump to line 40 because the condition on line 37 was always true

38 return super().expression_getter(field_expr) 

39 

40 get_value = self.__get__ 

41 get_property = self._expression_property_getter(property_name) 

42 return lambda record: (value := get_value(record)) and get_property(value) 

43 

44 def _expression_property_getter(self, property_name: str) -> Callable[[T], typing.Any]: 

45 """ Return a function that maps a field value (date or datetime) to the 

46 given ``property_name``. 

47 """ 

48 match property_name: 

49 case 'tz': 

50 return lambda value: value 

51 case 'year_number': 

52 return lambda value: value.year 

53 case 'quarter_number': 

54 return lambda value: value.month // 4 + 1 

55 case 'month_number': 

56 return lambda value: value.month 

57 case 'iso_week_number': 

58 return lambda value: value.isocalendar().week 

59 case 'day_of_year': 

60 return lambda value: value.timetuple().tm_yday 

61 case 'day_of_month': 

62 return lambda value: value.day 

63 case 'day_of_week': 

64 return lambda value: value.timetuple().tm_wday 

65 case 'hour_number' if self.type == 'datetime': 

66 return lambda value: value.hour 

67 case 'minute_number' if self.type == 'datetime': 

68 return lambda value: value.minute 

69 case 'second_number' if self.type == 'datetime': 

70 return lambda value: value.second 

71 case 'hour_number' | 'minute_number' | 'second_number': 

72 # for dates, it is always 0 

73 return lambda value: 0 

74 assert property_name not in READ_GROUP_NUMBER_GRANULARITY, f"Property not implemented {property_name}" 

75 raise ValueError( 

76 f"Error when processing the granularity {property_name} is not supported. " 

77 f"Only {', '.join(READ_GROUP_NUMBER_GRANULARITY.keys())} are supported" 

78 ) 

79 

80 def property_to_sql(self, field_sql: SQL, property_name: str, model: BaseModel, alias: str, query: Query) -> SQL: 

81 sql_expr = field_sql 

82 if self.type == 'datetime' and (timezone := model.env.context.get('tz')): 

83 # only use the timezone from the context 

84 if timezone in pytz.all_timezones_set: 

85 sql_expr = SQL("timezone(%s, timezone('UTC', %s))", timezone, sql_expr) 

86 else: 

87 _logger.warning("Grouping in unknown / legacy timezone %r", timezone) 

88 if property_name == 'tz': 

89 # set only the timezone 

90 return sql_expr 

91 if property_name not in READ_GROUP_NUMBER_GRANULARITY: 

92 raise ValueError(f'Error when processing the granularity {property_name} is not supported. Only {", ".join(READ_GROUP_NUMBER_GRANULARITY.keys())} are supported') 

93 granularity = READ_GROUP_NUMBER_GRANULARITY[property_name] 

94 sql_expr = SQL('date_part(%s, %s)', granularity, sql_expr) 

95 return sql_expr 

96 

97 def convert_to_column(self, value, record, values=None, validate=True): 

98 # we can write date/datetime directly using psycopg 

99 # except for company_dependent fields where we expect a string value 

100 value = self.convert_to_cache(value, record, validate=validate) 

101 if value and self.company_dependent: 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true

102 value = self.to_string(value) 

103 return value 

104 

105 

106class Date(BaseDate[date]): 

107 """ Encapsulates a python :class:`date <datetime.date>` object. """ 

108 type = 'date' 

109 _column_type = ('date', 'date') 

110 

111 @staticmethod 

112 def today(*args) -> date: 

113 """Return the current day in the format expected by the ORM. 

114 

115 .. note:: This function may be used to compute default values. 

116 """ 

117 return date.today() 

118 

119 @staticmethod 

120 def context_today(record: BaseModel, timestamp: date | datetime | None = None) -> date: 

121 """Return the current date as seen in the client's timezone in a format 

122 fit for date fields. 

123 

124 .. note:: This method may be used to compute default values. 

125 

126 :param record: recordset from which the timezone will be obtained. 

127 :param timestamp: optional datetime value to use instead of 

128 the current date and time (must be a datetime, regular dates 

129 can't be converted between timezones). 

130 """ 

131 today = timestamp or datetime.now() 

132 tz = record.env.tz 

133 today_utc = pytz.utc.localize(today, is_dst=False) # UTC = no DST 

134 today = today_utc.astimezone(tz) 

135 return today.date() 

136 

137 @staticmethod 

138 def to_date(value) -> date | None: 

139 """Attempt to convert ``value`` to a :class:`date` object. 

140 

141 .. warning:: 

142 

143 If a datetime object is given as value, 

144 it will be converted to a date object and all 

145 datetime-specific information will be lost (HMS, TZ, ...). 

146 

147 :param value: value to convert. 

148 :type value: str or date or datetime 

149 :return: an object representing ``value``. 

150 """ 

151 if not value: 

152 return None 

153 if isinstance(value, date): 

154 if isinstance(value, datetime): 

155 return value.date() 

156 return value 

157 value = value[:DATE_LENGTH] 

158 return datetime.strptime(value, DATE_FORMAT).date() 

159 

160 # kept for backwards compatibility, but consider `from_string` as deprecated, will probably 

161 # be removed after V12 

162 from_string = to_date 

163 

164 @staticmethod 

165 def to_string(value: date | typing.Literal[False]) -> str | typing.Literal[False]: 

166 """ 

167 Convert a :class:`date` or :class:`datetime` object to a string. 

168 

169 :param value: value to convert. 

170 :return: a string representing ``value`` in the server's date format, if ``value`` is of 

171 type :class:`datetime`, the hours, minute, seconds, tzinfo will be truncated. 

172 """ 

173 return value.strftime(DATE_FORMAT) if value else False 

174 

175 def convert_to_cache(self, value, record, validate=True): 

176 if not value: 

177 return None 

178 if isinstance(value, datetime): 

179 # TODO: better fix data files (crm demo data) 

180 value = value.date() 

181 # raise TypeError("%s (field %s) must be string or date, not datetime." % (value, self)) 

182 return self.to_date(value) 

183 

184 def convert_to_export(self, value, record): 

185 return self.to_date(value) or '' 

186 

187 def convert_to_display_name(self, value, record): 

188 return Date.to_string(value) 

189 

190 

191class Datetime(BaseDate[datetime]): 

192 """ Encapsulates a python :class:`datetime <datetime.datetime>` object. """ 

193 type = 'datetime' 

194 _column_type = ('timestamp', 'timestamp') 

195 

196 @staticmethod 

197 def now(*args) -> datetime: 

198 """Return the current day and time in the format expected by the ORM. 

199 

200 .. note:: This function may be used to compute default values. 

201 """ 

202 # microseconds must be annihilated as they don't comply with the server datetime format 

203 return datetime.now().replace(microsecond=0) 

204 

205 @staticmethod 

206 def today(*args) -> datetime: 

207 """Return the current day, at midnight (00:00:00).""" 

208 return Datetime.now().replace(hour=0, minute=0, second=0) 

209 

210 @staticmethod 

211 def context_timestamp(record: BaseModel, timestamp: datetime) -> datetime: 

212 """Return the given timestamp converted to the client's timezone. 

213 

214 .. note:: This method is *not* meant for use as a default initializer, 

215 because datetime fields are automatically converted upon 

216 display on client side. For default values, :meth:`now` 

217 should be used instead. 

218 

219 :param record: recordset from which the timezone will be obtained. 

220 :param datetime timestamp: naive datetime value (expressed in UTC) 

221 to be converted to the client timezone. 

222 :return: timestamp converted to timezone-aware datetime in context timezone. 

223 :rtype: datetime 

224 """ 

225 assert isinstance(timestamp, datetime), 'Datetime instance expected' 

226 tz = record.env.tz 

227 utc_timestamp = pytz.utc.localize(timestamp, is_dst=False) # UTC = no DST 

228 timestamp = utc_timestamp.astimezone(tz) 

229 return timestamp 

230 

231 @staticmethod 

232 def to_datetime(value) -> datetime | None: 

233 """Convert an ORM ``value`` into a :class:`datetime` value. 

234 

235 :param value: value to convert. 

236 :type value: str or date or datetime 

237 :return: an object representing ``value``. 

238 """ 

239 if not value: 

240 return None 

241 if isinstance(value, date): 

242 if isinstance(value, datetime): 

243 if value.tzinfo: 243 ↛ 244line 243 didn't jump to line 244 because the condition on line 243 was never true

244 raise ValueError("Datetime field expects a naive datetime: %s" % value) 

245 return value 

246 return datetime.combine(value, time.min) 

247 

248 # TODO: fix data files 

249 return datetime.strptime(value, DATETIME_FORMAT[:len(value)-2]) 

250 

251 # kept for backwards compatibility, but consider `from_string` as deprecated, will probably 

252 # be removed after V12 

253 from_string = to_datetime 

254 

255 @staticmethod 

256 def to_string(value: datetime | typing.Literal[False]) -> str | typing.Literal[False]: 

257 """Convert a :class:`datetime` or :class:`date` object to a string. 

258 

259 :param value: value to convert. 

260 :type value: datetime or date 

261 :return: a string representing ``value`` in the server's datetime format, 

262 if ``value`` is of type :class:`date`, 

263 the time portion will be midnight (00:00:00). 

264 """ 

265 return value.strftime(DATETIME_FORMAT) if value else False 

266 

267 def expression_getter(self, field_expr: str) -> Callable[[BaseModel], typing.Any]: 

268 if field_expr == self.name: 268 ↛ 270line 268 didn't jump to line 270 because the condition on line 268 was always true

269 return self.__get__ 

270 _fname, property_name = parse_field_expr(field_expr) 

271 get_property = self._expression_property_getter(property_name) 

272 

273 def getter(record): 

274 dt = self.__get__(record) 

275 if not dt: 

276 return False 

277 if (tz := record.env.context.get('tz')) and tz in pytz.all_timezones_set: 

278 # only use the timezone from the context 

279 dt = dt.astimezone(pytz.timezone(tz)) 

280 return get_property(dt) 

281 

282 return getter 

283 

284 def convert_to_cache(self, value, record, validate=True): 

285 return self.to_datetime(value) 

286 

287 def convert_to_export(self, value, record): 

288 value = self.convert_to_display_name(value, record) 

289 return self.to_datetime(value) or '' 

290 

291 def convert_to_display_name(self, value, record): 

292 if not value: 

293 return False 

294 return Datetime.to_string(Datetime.context_timestamp(record, value))