Задача, яка трапляється постійно: обмежити, які дати може вибрати користувач — дата операції не в майбутньому, дата початку не раніше 2026 року. Розглянемо, які засоби для цього дає Odoo — від опцій min_date/max_date у формі до обмежень на моделі, — як вони влаштовані в коді 19.0, що змінювалося з 15.0, і чому самого календаря іноді недостатньо, щоб покрити всі шляхи, якими дата потрапляє в базу.
Синтаксис <=16.0
До 16.0 включно окремих опцій немає — словник під ключем datepicker передається як є в бібліотеку tempusdominus:
<!-- 15.0 / 16.0 -->
<field name="invoice_date"
options="{'datepicker': {'minDate': '2026-01-01', 'maxDate': '2026-12-31'}}"/>
Odoo значення не розбирає, тож що саме прийнятно — вирішує бібліотека. Чи розуміє вона відносні вирази, я не перевіряв.
Важливо! На 16.0 є пастка, якої не було на 15.0: новий OWL-пікер оголошує minDate/maxDate як пропси типу luxon DateTime (web/static/src/core/datepicker/datepicker.js:404-405), а з арху приходить рядок. У продакшені це працює, у debug-режимі OWL валідує пропси і падає саме у розробника.
Синтаксис 17.0+
З 17.0 це повноцінні опції поля min_date / max_date, і приймають вони рівно два види значень — рядок 'today' або дату в SQL-форматі:
<!-- 17.0 / 18.0 / 19.0 -->
<field name="statement_date" options="{'max_date': 'today', 'warn_future': true}"/>
Відносних виразів на кшталт '+7d' чи 'now' тут немає. Посилання на інше поле запису немає на жодній версії. Ліміт — статичний літерал в арху представлення.
Як ліміт розбирається
На рівні поля, web/static/src/views/fields/datetime/datetime_field.js:
// web/static/src/views/fields/datetime/datetime_field.js:298-303
parseLimitDate(value) {
if (value === "today") {
return value;
}
return this.field.type === "date" ? deserializeDate(value) : deserializeDateTime(value);
}
Далі пікер перетворює 'today' на today(), обрізає межі до допустимого діапазону і — лише для поля типу date — розширює їх до початку і кінця дня:
// web/static/src/core/datetime/datetime_picker.js:401-409
this.maxDate = parseLimitDate(props.maxDate, MAX_VALID_DATE);
this.minDate = parseLimitDate(props.minDate, MIN_VALID_DATE);
if (this.props.type === "date") {
this.maxDate = this.maxDate.endOf("day");
this.minDate = this.minDate.startOf("day");
}
if (this.maxDate < this.minDate) {
throw new Error(`DateTimePicker error: given "maxDate" comes before "minDate".`);
}
deserializeDate — це DateTime.fromSQL (web/static/src/core/l10n/dates.js:692-697). Літерал у будь-якому іншому форматі, '31.12.2026' наприклад, дає Invalid DateTime, усі порівняння з ним повертають false, і календар сіріє цілком — без жодної помилки в консолі. Це висновок з коду, не відтворено вживу. Але наслідок передбачуваний: користувач, якому не дали клікнути, починає вводити дату руками. А це, як побачимо нижче, якраз той шлях, де ліміту немає.
Що бачить користувач: на 19.0 день поза межами отримує opacity-50 і втрачає cursor-pointer (web/static/src/core/datetime/datetime_picker.xml:37-40), на 17.0/18.0 — клас o_out_of_range, на 15.0/16.0 — disabled з tempusdominus. Дні не ховаються, лише сіріють. Клік по сірому дню не робить нічого — ні повідомлення, ні зміни значення.

Поле date з max_date: 'today' на реальній базі 19.0: сьогодні 7 жовтня, 8-ме і далі сірі, вибрано 4-те.

Для порівняння — пікер без ліміту (поле datetime запланованої дії): активні всі дні, і минулі, і майбутні.
Datetime: дві пастки
Перша — max_date: 'today' на полі datetime означає сьогодні 00:00:00. Розширення до кінця дня в коді вище обгорнуте в type === "date". Клітинка сьогоднішнього дня в календарі активна, бо для неї перевіряється перетин діапазонів. А при виборі пікер підставляє поточний час і перевіряє вже входження:
// web/static/src/core/datetime/datetime_picker.js:587-595
if (this.props.type === "datetime") {
// Adjusts result according to the current time values
const { hour, minute, second } = this.state.timeValues[valueIndex];
result[valueIndex] = result[valueIndex].set({ hour, minute, second });
}
if (!isInRange(result[valueIndex], [this.minDate, this.maxDate])) {
// Date is outside range defined by min and max dates
return false;
}
Тобто клік по сьогоднішній даті мовчки нічого не робить, якщо на годиннику не 00:00. Для datetime замість 'today' пишіть явний момент: '2026-12-31 23:59:59'.
Друга — літерал для datetime розбирається в UTC:
// web/static/src/core/l10n/dates.js:703-709
export function deserializeDateTime(value, options = {}) {
return DateTime.fromSQL(value, { numberingSystem: "latn", zone: "utc" })
.setZone(options?.tz || "default")
...
Межа, записана як київський настінний час, зсунута на три години влітку і на дві взимку.
Введення з клавіатури ліміт не перевіряє
Найважливіше в усій статті. Текстове поле пікера розбирається окремим шляхом:
// web/static/src/core/datetime/datetimepicker_service.js:398-416
function updateValueFromInputs() {
const values = zipWith(
getInputs(),
ensureArray(pickerProps.value),
(el, currentValue) => {
if (!el || el.tagName?.toLowerCase() !== "input") {
return currentValue;
}
const [parsedValue, error] = safeConvert("parse", el.value);
if (error) {
updateInput(el, currentValue);
return currentValue;
} else {
return parsedValue;
}
}
);
updateValue(values.length === 2 ? values : values[0], "date", "input");
}
Розібралося — значить прийнято. Порівняння з minDate/maxDate тут немає, значення йде в record.update() і зберігається. Так само на 18.0 і 17.0; на 15.0 isValid() віджета лише парсить рядок, на 16.0 — те саме. На всіх п'яти версіях захищений тільки шлях мишкою.
Неправильно — вважати max_date правилом даних. Правильно — вважати його підказкою інтерфейсу: він працює рівно в одному <field> одного представлення і лише поки користувач клікає.
Як неправильна дата все одно потрапляє в базу
Крім ручного введення:
- імпорт —
load()іде вcreate(), віджета там немає; - RPC,
web_save, інтеграції — опція живе лише в арху; - інше представлення тієї ж моделі — інлайн-редагування в списку, другий візард, портал: опція задається на кожне входження
<field>окремо; default=fields.Date.todayчи compute — вони не питають пікер;- записи, що існували до появи правила.
Що справді тримає правило
@api.constrains — і з власним застереженням, задокументованим прямо в декораторі:
# odoo/orm/decorators.py:110-115
``@constrains`` will be triggered only if the declared fields in the
decorated method are included in the ``create`` or ``write`` call.
It implies that fields not present in a view will not trigger a call
during a record creation. A override of ``create`` is necessary to make
sure a constraint will always be triggered (e.g. to test the absence of
value).
Плюс точкові імена полів (partner_id.date) у @constrains просто ігноруються. На рівні бази — CHECK, і на 19.0 вже не через _sql_constraints: атрибут лише логує, що більше не підтримується (odoo/orm/model_classes.py:162-164), замість нього models.Constraint. CHECK не вміє now(), тож «не в майбутньому» лишається Python-правилом, а «кінець не раніше початку» лягає в CHECK природно.
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class KwBankStatementLine(models.Model):
_inherit = 'kw.bank.statement.line'
_operation_before_value_date = models.Constraint(
'CHECK (operation_date <= value_date)',
'The operation date cannot be after the value date.',
)
@api.constrains('operation_date')
def _check_operation_date_not_future(self):
for rec in self:
if rec.operation_date \
and rec.operation_date > fields.Date.context_today(rec):
raise ValidationError(_(
'Operation date %(date)s is in the future.',
date=rec.operation_date,
))
Модель і поля тут ілюстративні. До 18.0 включно замість models.Constraint — кортеж у _sql_constraints.
Яке саме «сьогодні»
Три різних «сьогодні», і вони розходяться:
'today'у пікері —DateTime.local().startOf("day")(web/static/src/core/l10n/dates.js:395-397), тобто часовий пояс браузера, а не користувача Odoo: Odoo виставляє luxon лише локаль (localization_service.js:107), не зону;fields.Date.today()—date.today()процесу сервера, на практиці UTC (odoo/orm/fields_temporal.py:112-117);fields.Date.context_today(rec)— поточний момент, переведений уenv.tz, тобто в часовий пояс користувача (odoo/orm/fields_temporal.py:120-135).
Київ, літо, 15 липня 01:30 — це 14 липня 22:30 UTC. Пікер з max_date: 'today' дозволяє 15 липня. Обмеження з fields.Date.today() бачить 14-те і відмовляє: «дата в майбутньому». З 00:00 до 03:00 щоночі користувач отримує помилку на дату, яку йому щойно дозволив календар. З context_today правило збігається з пікером.
Я б тримався простого правила: якщо в арху стоїть 'today' і правило про людський день — порівнюйте з context_today. Date.today() — лише коли правило справді прив'язане до годинника сервера, наприклад коли сама синхронізація теж рахує дні в UTC. Тоді розбіжність з пікером у нічні години — свідомий компроміс, а не баг.
Тест, який ловить довіру до max_date
@tagged('post_install', '-at_install')
class TestOperationDateLimit(TransactionCase):
def setUp(self):
super().setUp()
self.env.user.tz = 'Europe/Kyiv'
self.today = fields.Date.context_today(self.env.user)
def test_today_is_accepted(self):
self.env['kw.bank.statement.line'].create(
{'operation_date': self.today})
def test_import_future_date_raises(self):
res = self.env['kw.bank.statement.line'].load(
['operation_date'], [[str(self.today + timedelta(days=1))]])
self.assertTrue(res['messages'], 'import must be rejected too')
Тест імпорту падає, якщо ви покладалися на max_date. Тест на сьогоднішній день ловить надто суворе >= замість >. До них — по тесту на create і write з майбутньою датою.
Де межі
Офіційна документація представлень (view_architectures для 19.0) не згадує ні min_date, ні max_date, ні warn_future, ні min_precision. Авторитетний список — supportedOptions у datetime_field.js відповідної версії. warn_future — червоний текст з підказкою, ніколи не блокування. На 19.0 додалися min_precision/max_precision (days/months/years/decades) — обмеження рівня зуму, а не дат. Обмеження, яке залежить від даних самого запису, через опції не задати. Шлях один — свій OWL-віджет з isDateValid у пікері, і введення з клавіатури обходить його так само.
Код цитовано з 19.0; синтаксис і шлях введення звірено з вихідним кодом 15.0–18.0.
Ще нотатки з практики Odoo — у Telegram: @kitworks_modules