Задача: зробити імпорт даних в Odoo, який не доведеться переписувати під кожен новий формат файлу й кожну нову модель. Класичний варіант — окремий скрипт чи візард під кожен кейс клієнта: сьогодні product.template з Excel, завтра account.move з вивантаження 1С, і щоразу наново пишеться парсинг, пошук дублів і create/write. Ми вже писали про ручне перенесення товарів з іншої бази — тут та сама задача, тільки на масштабі: 17 модулів kw_generic_import* замінюють цілий клас разових скриптів однією конфігурованою моделлю.
Конфігурація без коду
Розглянемо дві моделі, які і є конфігурацією. kw.generic.import.template — профіль імпорту: цільова модель, формат джерела, стратегія збігу.
model_id = fields.Many2one('ir.model', required=True)
format_id = fields.Many2one('kw.generic.import.source.format', required=True)
match_strategy = fields.Selection([
('create_only', 'Create only'),
('update_only', 'Update only'),
('upsert', 'Create or update')], default='upsert')
match_field_ids = fields.Many2many('ir.model.fields')
on_error = fields.Selection([
('skip', 'Skip row and continue'),
('stop', 'Stop on first error'),
('rollback_all', 'Roll back the whole import')], default='skip')
batch_size = fields.Integer(default=200)
kw.generic.import.mapping.line — один рядок маппінгу, «колонка файлу → поле моделі»:
source = fields.Char(required=True) # заголовок колонки / JSON-ключ
field_id = fields.Many2one('ir.model.fields', required=True)
converter_id = fields.Many2one('kw.generic.import.converter')
relation_search_field = fields.Char() # поле пошуку для m2o/m2m
on_missing = fields.Selection([
('error', 'Raise error'),
('create', 'Create related record'),
('skip', 'Leave empty')], default='error')
is_required = fields.Boolean()
Адміністратор налаштовує обидва записи через UI — python при цьому не пишеться взагалі. Це і є межа «імпорту без коду»: доки задача — мапінг колонок і стандартний пошук дублів, коду не буде.
Ядро проти доменних розширень
Головна архітектурна ідея репозиторію: ядро kw_generic_import не залежить від жодного функціонального модуля (sale, crm, account…) і ніколи не додає поле чи import-прапорець у бізнес-модель. Специфіка моделі живе в handler-адаптері — AbstractModel, який рушій диспетчерить за конвенцією імені:
class KwGenericImportHandler(models.AbstractModel):
_name = 'kw.generic.import.handler'
def _dispatch(self, base, model_name, *args):
specific = '%s_%s' % (base, model_name.replace('.', '_'))
method = specific if hasattr(self, specific) else '%s_default' % base
return getattr(self, method)(*args)
def match_domain(self, model_name, values, template):
return self._dispatch(
'_match_domain', model_name, model_name, values, template)
Неправильно — додати import_source_id чи прапорець на crm.lead, щоб позначати імпортовані записи.
Правильно — _inherit того самого kw.generic.import.handler в окремому модулі kw_generic_import_crm і реалізувати лише _match_domain_crm_lead: пошук іде спершу за email_from, і лише якщо там порожньо — за name. Це пріоритетний вибір по черзі, перше непорожнє поле й вирішує домен, а не поєднання обох умов через AND:
def _match_domain_crm_lead(self, model_name, values, template):
for field in ('email_from', 'name'):
if values.get(field):
return [(field, '=', values[field])]
return self._match_domain_default(model_name, values, template)
crm.lead при цьому не змінюється жодним рядком. Той самий метод для account.move в kw_generic_import_account уже розрізняє рахунок/накладну/проведення за move_type і додатково валідує баланс дебету й кредиту перед записом — уся специфіка бухгалтерії лишається в одному модулі, ядро про account.move нічого не знає.
Зв'язки: пошук, створення, неоднозначність
Реляційні поля (m2o/m2m) резолвляться конвертером, не handler'ом — _convert_m2o_by_name шукає запис за relation_search_field (за замовчуванням name), а on_missing мапінг-рядка вирішує, що робити з промахом:
def _resolve_relation(self, value, ctx, name_field):
comodel = ctx.get('comodel')
text = str(value).strip() if value else ''
if not text or not comodel:
return False # немає значення чи не задано comodel — просто пусто
records = self.env[comodel].search(
[(name_field, '=', text)], order='id')
if records:
return records[0] # найстарший запис — детермінований вибір при дублях
on_missing = ctx.get('on_missing') or 'error'
if on_missing == 'skip':
return False
if on_missing == 'create':
return self.env[comodel].create({name_field: text})
raise UserError(...)
Важливо! Пошук завжди order='id', а не дефолтний _order моделі: сортування за sequence (типовий дефолт багатьох моделей) ставить NULL — тобто щойно створений запис — у кінець вибірки в PostgreSQL, і старий запис із заповненим sequence міг би непомітно перехопити збіг замість запису, щойно створеного попереднім рядком імпорту. При кількох збігах рушій пише warning у лог і бере найстаріший запис — мовчки нічого не вирішує.
Валідація рядка і що з ним відбувається далі
Обов'язковість перевіряється двічі: мапінг-рядком (is_required) і самою моделлю (required=True без ORM default). Друга перевірка врятувала від реального бага: порожня колонка «DATE», змаплена на необов'язкове stock.picking.scheduled_date, тихо писала False в існуючий запис — і тільки NOT NULL на stock.move.date (інше поле, через inverse) це ловив, причому не на dry run, а лише на реальному прогоні. Тепер рядок з колонкою, якої взагалі немає у файлі, для цього поля просто пропускається — на відміну від порожньої комірки, яка й далі йде через конвертер і перевірку.
Транзакційність і on_error
Стратегія on_error на шаблоні визначає, чи один поганий рядок валить увесь імпорт.
skip/stop— кожен рядок обробляється у власній незалежній транзакції (_in_new_transaction); попередні успішні рядки лишаються закомічені незалежно від того, що станеться далі. Різниця лише в тому, чи продовжує рушій після помилки.rollback_all— увесь батч обробляється в однійSAVEPOINT; будь-який виняток відкочує назад геть усе, включно з уже «успішними» рядками цього ж виклику.
Ну ок, тобто skip і rollback_all — це буквально протилежні гарантії, і обирає їх адміністратор на шаблоні, а не розробник у коді.
Dry run працює тією ж механікою: увесь файл проганяється у вкладеній транзакції з SAVEPOINT на кожен рядок (щоб зловити навіть DB-рівні обмеження), а потім уся вкладена транзакція відкочується — прев'ю рахує created/updated/error, а в базі не змінюється нічого. Я завжди раджу перший прогін нового шаблону робити саме через dry run: практика показує, що там ловляться не помилки конвертерів, а якраз required-поля з ORM-дефолтом, які легко проґавити на етапі маппінгу.
Асинхронний режим
Cron сам ділить великий файл на батчі batch_size рядків за тік — сесія переходить у queued, і _run_cron_chunk дожовує її шматками. kw_generic_import_generic_task_queue замінює ir.cron на воркер generic_task_queue, коли треба обробляти без пауз між тіками:
class KwGenericImportSession(models.Model):
_inherit = 'kw.generic.import.session'
background_backend = fields.Selection(
selection_add=[('gtq', 'Generic Task Queue')],
ondelete={'gtq': 'set default'})
def _enqueue_background(self):
self._check_background_user_not_superuser()
if 'generic.task.queue.task' not in self.env:
return super()._enqueue_background()
self.write({'state': 'queued', 'background_backend': 'gtq'})
self.env['generic.task.queue.task'].create_task(
'kw_generic_import.session.run',
params={'session_id': self.id},
name='Generic Import: %s' % self.name,
channel='import',
unique_key='kw_generic_import.session.%d' % self.id,
on_conflict='reuse-running')
Перевірка _check_background_user_not_superuser() не декоративна: асинхронний запуск під суперюзером тихо вимикає access rules і частину валідацій, і бага, який на dry run під звичайним користувачем вискочив би одразу, у фоні просто не станеться — рядок мовчки запишеться. А якщо модуль generic_task_queue в базі не встановлений, _enqueue_background не падає — фолбек return super()._enqueue_background() відкочує сесію на нативний ir.cron: асинхронність нікуди не зникає, просто повертається до обробки батчами по тіку, без черги.
Нативний cron забирає в обробку лише сесії з background_backend = 'cron', тому обидва воркери ніколи не чіпають одну й ту саму сесію одночасно — координації між ними не потрібно.
Межа конфігурованості проходить рівно там, де закінчується мапінг «колонка → поле» і стандартний create/write. Новий формат файлу (_parse_<code>), нестандартна умова пошуку дубля (_match_domain_*) і пост-обробка на кшталт реконсиляції платежу (_post_process_account_payment у kw_generic_import_account) — усе це вже python-метод в окремому модулі, не запис у шаблоні. А ось документ із заголовком і рядками, що групуються за кольором комірки, коду якраз не вимагає: document_row_style_fill_argb/document_row_style_bold на самому шаблоні — це штатна UI-конфігурація, не приклад межі.