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
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:05 +0000
1from __future__ import annotations
3import typing
4from datetime import date, datetime, time
6import pytz
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
12from .fields import Field, _logger
13from .utils import parse_field_expr, READ_GROUP_NUMBER_GRANULARITY
15if typing.TYPE_CHECKING:
16 from collections.abc import Callable
17 from odoo.tools import Query
19 from .models import BaseModel
21T = typing.TypeVar("T")
23DATE_LENGTH = len(date.today().strftime(DATE_FORMAT))
24DATETIME_LENGTH = len(datetime.now().strftime(DATETIME_FORMAT))
27class BaseDate(Field[T | typing.Literal[False]], typing.Generic[T]):
28 """ Common field properties for Date and Datetime. """
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)
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)
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)
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 )
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
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
106class Date(BaseDate[date]):
107 """ Encapsulates a python :class:`date <datetime.date>` object. """
108 type = 'date'
109 _column_type = ('date', 'date')
111 @staticmethod
112 def today(*args) -> date:
113 """Return the current day in the format expected by the ORM.
115 .. note:: This function may be used to compute default values.
116 """
117 return date.today()
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.
124 .. note:: This method may be used to compute default values.
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()
137 @staticmethod
138 def to_date(value) -> date | None:
139 """Attempt to convert ``value`` to a :class:`date` object.
141 .. warning::
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, ...).
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()
160 # kept for backwards compatibility, but consider `from_string` as deprecated, will probably
161 # be removed after V12
162 from_string = to_date
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.
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
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)
184 def convert_to_export(self, value, record):
185 return self.to_date(value) or ''
187 def convert_to_display_name(self, value, record):
188 return Date.to_string(value)
191class Datetime(BaseDate[datetime]):
192 """ Encapsulates a python :class:`datetime <datetime.datetime>` object. """
193 type = 'datetime'
194 _column_type = ('timestamp', 'timestamp')
196 @staticmethod
197 def now(*args) -> datetime:
198 """Return the current day and time in the format expected by the ORM.
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)
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)
210 @staticmethod
211 def context_timestamp(record: BaseModel, timestamp: datetime) -> datetime:
212 """Return the given timestamp converted to the client's timezone.
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.
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
231 @staticmethod
232 def to_datetime(value) -> datetime | None:
233 """Convert an ORM ``value`` into a :class:`datetime` value.
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)
248 # TODO: fix data files
249 return datetime.strptime(value, DATETIME_FORMAT[:len(value)-2])
251 # kept for backwards compatibility, but consider `from_string` as deprecated, will probably
252 # be removed after V12
253 from_string = to_datetime
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.
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
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)
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)
282 return getter
284 def convert_to_cache(self, value, record, validate=True):
285 return self.to_datetime(value)
287 def convert_to_export(self, value, record):
288 value = self.convert_to_display_name(value, record)
289 return self.to_datetime(value) or ''
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))