Skip to Content

Ліміти дат у полях Date і Datetime в Odoo: min_date, max_date

Задача, яка трапляється постійно: обмежити, які дати може вибрати користувач — дата операції не в майбутньому, дата початку не раніше 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' — дні після сьогоднішнього сірі

Поле 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

Ліміти дат у полях Date і Datetime в Odoo: min_date, max_date
KitWorks, Viktor Kachkovskiy 9 жовтня 2026 р.
Поділитися цією публікацією
Архів