Skip to Content

Реєстр провайдерів в Odoo: чотирнадцятий провайдер — це новий модуль, не правка ядра

У нас дванадцять модулів SMS-шлюзів: kw_sms_turbosms, kw_sms_alphasms, kw_sms_smsfly, kw_sms_esputnik, kw_sms_sendpulse, kw_sms_smsclub, kw_sms_binotel, kw_sms_zadarma, kw_sms_mobizon, kw_sms_letsads, kw_sms_omnicell, kw_sms_inteltelecom. У кожного — свій формат номера телефону, свій спосіб авторизації (Bearer, Basic, HMAC-підпис) і свій словник статусів доставки. Усі дванадцять сидять на одному ядрі kw_sms_api, яке жодного разу не довелось міняти заради конкретного тринадцятого чи чотирнадцятого провайдера. Розглянемо, як це влаштовано.

Ядро при цьому не заморожене. За те саме вікно, поки з'являлись binotel, omnicell, smsclub, sendpulse, esputnik та inteltelecom (жовтень 2025 — березень 2026), файли kw_sms_api правились щонайменше тричі: фікс домену active_idid, CI-очищення тестів одразу після omnicell і фіча вибору дефолтного провайдера в Налаштуваннях. Жодна з цих правок не була спричинена конкретним новим провайдером — незалежна робота ядра просто збіглась у часі з ростом списку шлюзів. Різниця принципова: ядро розвивається саме по собі, але не тому, що хтось додав тринадцятий шлюз.

Контракт

Ядро визначає модель kw.sms.provider з чотирма методами, кожен — заглушка:

class SmsProvider(models.Model):
    _name = 'kw.sms.provider'
    _inherit = ['kw.api.credential']

    @use_fname()
    def sms_send(self, sms_id, **kwargs):
        ...

    @use_fname()
    def sms_status(self, sms_id, **kwargs):
        ...

    @use_fname()
    def sms_sender(self, sender_name):
        ...

    @use_fname()
    def get_balance(self):
        ...

Провайдер-модуль ці методи не перевизначає. Він додає СВОЇ, з суфіксом технічної назви провайдера: sms_send_binotel, sms_status_binotel, get_balance_binotel. Ніякого успадкування конкретного методу, ніякого super().

Диспетчер — 14 рядків

Увесь реєстр — це декоратор use_fname з kw_mixin/tools.py:

def use_fname(*deco_args):
    def decorator(func, *aa):
        def wrapper(*args, **kwargs):
            self = args[0]
            self.ensure_one()
            f = deco_args[0] if deco_args else 'code'
            fname = f'{func.__name__}_{getattr(self, f)}'
            if hasattr(self, fname):
                return getattr(self, fname)(*args[1:], **kwargs)
            return func(*args, **kwargs)
        return wrapper
    return decorator

code — related-поле, яке дивиться на api_connector_id.name (технічну назву конектора). Викликаємо provider.sms_send(id) — декоратор будує ім'я sms_send_binotel, перевіряє, чи є в об'єкта такий атрибут, і якщо є — викликає його замість базового методу. Немає такого методу — падає в generic-заглушку.

Неправильно (уявний варіант — ну ок, дванадцять гілок ще можна тримати в голові, а от тринадцяту вже ні):

def sms_send(self, sms_id):
    if self.code == 'turbosms':
        return self._send_turbosms(sms_id)
    elif self.code == 'alphasms':
        return self._send_alphasms(sms_id)
    elif self.code == 'smsclub':
        ...
    # і ще дев'ять гілок

Правильно — реальний код ядра вище: sms_send не знає, скільки провайдерів існує і які в них назви. Диспетчеризація йде через hasattr, а не через центральний if/elif, який довелося б редагувати заради кожного нового модуля.

Реєстрація — це XML-запис, не рядок у реєстрі

Провайдер «реєструється» тим, що створює запис конектора зі своєю технічною назвою. Ось увесь data.xml модуля kw_sms_binotel:

<record id="sms_connector_binotel" model="kw.sms.connector">
    <field name="name">binotel</field>
    <field name="type">json</field>
    <field name="api_url">https://api.binotel.com</field>
    <field name="is_api_token_used" eval="True"/>
    <field name="is_api_token_static" eval="True"/>
</record>
<record id="kw_sms_provider_binotel" model="kw.sms.provider">
    <field name="name">binotel</field>
    <field name="sequence">200</field>
    <field name="api_connector_id" ref="sms_connector_binotel"/>
</record>

name конектора стає code кредита — і саме за цим значенням use_fname шукає метод з суфіксом _binotel. Жодного Python-виклику на кшталт register_provider('binotel', ...) у коді немає взагалі.

Вибір провайдера, коли їх кілька

Тут ядро не мудрує. kw.sms.provider._order = 'sequence, name', а вибір «дефолтного» провайдера — параметр конфігурації:

config_value = self.env['ir.config_parameter'].sudo() \
    .get_param('kw_sms_api.default_provider_id')
try:
    provider_id = SmsProvider.browse(int(config_value)).exists()
except (ValueError, TypeError):
    provider_id = None
if not provider_id:
    provider_id = SmsProvider.search([], limit=1)

Автоматичного роутингу за ціною чи країною номера немає — просто один ir.config_parameter і sequence для сортування у списку. Вибір провайдера — бізнесове рішення клієнта, не задача ядра.

Що уніфіковано, а що — принципово ні

Уніфіковано мінімум: state (queued / sent / error) і три технічні поля — kw_sms_message_ref, kw_sms_response_code, kw_sms_response_status. Кожен провайдер зобов'язаний записати ці чотири значення, більше ядро від нього не вимагає. HTTP-шар теж спільний — api_request з kw.api.credential один на всіх, з логуванням і обробкою винятків.

А от формат номера телефону і словник статусів доставки — не уніфіковані, і це свідомо. TurboSMS і Binotel передають номер як є, з +. AlphaSMS і SMSClub роблять sms.number.replace('+', ''). eSputnik іде далі і примусово підставляє код країни:

phone_number = sms.number.replace('+', '')
if not phone_number.startswith('380'):
    phone_number = '380' + phone_number.lstrip('0')

Словники статусів доставки теж живуть у кожного провайдера окремо, і тут є цікавий виняток з правила «нічого не повторюється»: у SMSClub ENROUTE/DELIVRD мапляться на sent, EXPIRED/UNDELIV/REJECTD — на error, і той самий маппінг DELIVRD/EXPIRED/UNDELIV буквально повторюється в kw_sms_smsfly. Ну ок, дванадцять незалежних провайдер-модулів — і двоє з них скопіювали один і той самий словник кодів. Це майже напевно збіг зі спільного SMPP-стандарту статусів доставки в телекомі, а не свідоме перевикористання коду — але сама відсутність спільного enum для цих статусів у ядрі це вже тріщина патерну: копіпаст між kw_sms_smsclub і kw_sms_smsfly ядро жодним чином не запобігає і не ловить. Спроба зробити один спільний конвертер номерів чи один спільний словник статусів на тринадцять різних API означала б написати абстракцію, яка все одно ламається на чотирнадцятому провайдері. Ядро уніфікує тільки те, що дійсно потрібно решті Odoo (стан sms.sms), і залишає провайдер-специфіку провайдеру — ціною зрідка дублювати такі словники вручну.

Важливо! Ядро також не примушує провайдер перевіряти тип повідомлення (SMS чи Viber) всередині sms_send_<code> — це відповідальність кожного модуля. 2026-04-08 сім із дванадцяти провайдерних модулів одночасно забули цю перевірку: Viber-повідомлення тихо йшли як звичайна SMS. Ядро цього не ловить, бо не знає, які провайдери взагалі підтримують Viber. Зловили регресійним тестом на повний ланцюжок диспетчеризації, не на рівні ядра.

Тестування без реальних грошей і реальних SMS

Є окремий інстальований модуль test_kw_sms_api (категорія Hidden/Tests, залежить лише від kw_sms_api) з фейковим провайдером:

class SmsProviderDummy(models.Model):
    _inherit = 'kw.sms.provider'
    _dispatch_log = []

    def sms_send_dummy(self, sms_id, **kwargs):
        sms = self.env['sms.sms'].browse(sms_id)
        if sms.kw_sms_viber_type in ('viber', 'sms_viber'):
            return self.viber_send_dummy(sms_id, **kwargs)
        type(self)._dispatch_log.append(('sms', sms_id, sms.kw_sms_viber_type))
        sms.write({'state': 'sent', 'kw_sms_message_ref': f'dummy-sms-{sms_id}'})

Тест створює конектор з name = 'dummy', і use_fname знаходить sms_send_dummy так само, як знайшов би sms_send_turbosms. Перевіряється весь ланцюжок sms._send() → provider.sms_send() → sms_send_dummy() / viber_send_dummy() — без жодного HTTP-запиту.

Для більшості провайдер-модулів тест простіший, ніж могло б здатись: мережевий I/O стікається в один метод api_request, який усередині викликає requests.request. Тому здебільшого достатньо замокати саме цю точку — реальний тест SMSClub ще й виставляє status_code і створює запис sms.sms перед викликом, інакше is_api_success() порівнює 200 <= <MagicMock>, що працює лише випадково завдяки поблажливості MagicMock:

@patch('requests.request')
def test_sms_send_success(self, mock_request):
    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.json.return_value = {
        'success_request': {'info': {'12345': '380501234567'}}}
    mock_request.return_value = mock_response

    sms = self.env['sms.sms'].create({
        'number': '+380501234567', 'body': 'Test message',
        'kw_sms_sender_name': 'TestSender'})
    self.provider.sms_send_smsclub(sms.id)
    sms.invalidate_recordset()
    self.assertEqual(sms.state, 'sent')

Провайдер-специфічний парсинг, маппінг статусів і запис у sms.sms перевіряються по-справжньому — гроші і реальний шлюз для цього не потрібні.

Важливо! Один провайдер із дванадцяти цю схему ламає. kw_sms_sendpulse отримує OAuth-токен викликом requests.post(...) напряму, в обхід спільного api_request — мок @patch('requests.request') цей виклик не бачить узагалі, бо це інша точка входу requests. Тест OAuth-кроку для SendPulse доводиться патчити окремо, @patch('requests.post'). Незручно рівно настільки, наскільки й виглядає: один модуль з дванадцяти живе за іншим контрактом мережевого шару, і про це треба пам'ятати, перш ніж копіювати тестовий шаблон на новий провайдер.

Скільки коду в найменшому провайдері

kw_sms_binotel — весь models/sms_provider.py, 53 рядки:

class SmsProvider(models.Model):
    _inherit = 'kw.sms.provider'

    api_key = fields.Char()
    api_secret = fields.Char()

    def sms_send_binotel(self, sms_id, **kwargs):
        sms = self.env['sms.sms'].browse(sms_id)
        request_data = {
            'phoneNumber': sms.number,
            'senderId': self.binotel_sms_sender(sms.kw_sms_sender_name),
            'content': sms.body,
            'key': self.api_key,
            'secret': self.api_secret,
        }
        r = self.api_request('POST', '/sms/send-sms.json', json=request_data)
        if not r:
            return
        param = {}
        if r.get('status') == 'success':
            param['state'] = 'sent'
            if 'messageId' in r:
                param['kw_sms_message_ref'] = str(r['messageId'])
        else:
            param['state'] = 'error'
        if 'status' in r:
            param['kw_sms_response_status'] = r['status']
        if 'message' in r:
            param['kw_sms_response_code'] = r.get('message', '')
        sms.write(param)

    def binotel_sms_sender(self, sender_name):
        self.ensure_one()
        sender = self.env['kw.sms.sender'].search([
            ('provider_id', '=', self.id),
            ('name', '=', sender_name),
        ], limit=1)
        if sender:
            return sender_name
        return self.default_sender_id.name

Плюс data.xml на 22 рядки (наведений вище) і __manifest__.py з єдиною залежністю kw_sms_api. Це весь модуль. Я вважаю це найпереконливішим аргументом на користь підходу: 53 рядки Python — і провайдер живий, з обов'язковим записом kw_sms_response_status і kw_sms_response_code, які контракт вимагає від кожного провайдера.

Важливо! kw_sms_binotel не перевизначає sms_status_binotel. Виклик kw_sms_status() для цього провайдера падає в generic-заглушку базового класу, яка тільки пише лог і нічого не змінює в sms.state. Перевірка статусу доставки для Binotel у поточному вигляді мовчки нічого не робить — це не помилка ядра, а недописана частина конкретного провайдера, і ядро цього не приховує.

Той самий контракт — code-суфіксовані методи поверх use_fname — повторюється в kw.payment.credential (14 платіжних провайдерів, payment_wayforpay, payment_smartpay, payment_genome та інші), у kw.ai.credential (kw_ai_openai, kw_ai_anthropic, kw_ai_google) і в банківських виписках (kw_bank_statement_privat24_api, kw_bank_statement_monobank_corp_api). Усі чотири домени буквально успадковують один і той самий AbstractModel kw.api.credential — те саме code-поле, той самий базовий @use_fname()-декорований api_request. Це не подібність за стилем коду, це один спільний базовий клас на SMS, платежі, AI і банківські виписки. SMS тут просто найбільший наочний приклад — дванадцять провайдерів видно неозброєним оком, а патерн один і той самий.

Реєстр провайдерів в Odoo: чотирнадцятий провайдер — це новий модуль, не правка ядра
KitWorks, Volodymyr Karabanov 15 серпня 2026 р.
Поділитися цією публікацією
Теги
Архів