Building Custom Odoo Modules: Developer Tutorial

Step-by-step tutorial for building custom Odoo 19 modules. Covers module structure, models, views, security, wizards, and best practices for production-ready code.

E
ECOSIRE Research and Development Team
|2026年3月19日6 分で読める1.2k 語数|

カスタム Odoo モジュールの構築: 開発者チュートリアル

Odoo のモジュール システムは、ERP の世界で最も強力な拡張フレームワークの 1 つです。 Odoo のすべての機能 (会計から在庫、CRM まで) はモジュールです。これは、カスタム機能の構築が Odoo 自身の開発者が使用しているのとまったく同じパターンに従っており、コアをフォークすることなく完全なフレームワークにアクセスできることを意味します。

このチュートリアルでは、ディレクトリ構造のスキャフォールディングとモデルの定義から、ビューの作成、アクセスの保護、運用環境へのデプロイまで、カスタム Odoo 19 モジュールのライフサイクル全体をカバーします。最終的には、Odoo 19 Enterprise の規則に従って、市場に投入できる実用的なモジュールが完成します。

重要なポイント

  • すべての Odoo モジュールは __manifest__.py 記述子を持つ Python パッケージです
  • モデルは models.Model から継承し、PostgreSQL テーブルに直接マップします
  • ビューは XML で定義され、モデル フィールドを名前で参照します
  • ir.model.access CSV およびレコード ルールを通じてセキュリティが適用されます
  • ウィザード (TransientModel) は複数ステップのユーザー操作を処理します
  • 計算フィールドと onchange メソッドは関連フィールドを動的に更新します
  • 自動化されたアクションとスケジュールされたジョブは、トリガーでサーバー側のロジックを実行します
  • モジュールの依存関係により、正しいロード順序と機能の可用性が保証されます

モジュールの構造と足場

すべての Odoo モジュールは、特定の構造を持つディレクトリです。 Odoo の組み込み scaffold コマンドを使用してボイラープレートを生成します。

# From your Odoo addons directory
python odoo-bin scaffold my_module /path/to/addons

これにより、以下が生成されます。

my_module/
├── __init__.py
├── __manifest__.py
├── models/
│   ├── __init__.py
│   └── my_model.py
├── views/
│   └── my_model_views.xml
├── security/
│   ├── ir.model.access.csv
│   └── my_module_security.xml
├── data/
│   └── my_module_data.xml
├── wizard/
│   └── my_wizard.py
├── report/
│   └── my_report.xml
└── static/
    └── src/
        └── js/

マニフェスト ファイル (__manifest__.py)

{
    'name': 'My Custom Module',
    'version': '19.0.1.0.0',
    'summary': 'Short description for module list',
    'description': """
        Extended description of what this module does.
        Can be multi-line RST text.
    """,
    'author': 'ECOSIRE Private Limited',
    'website': 'https://ecosire.com',
    'category': 'Sales/CRM',
    'depends': ['sale', 'account', 'stock'],
    'data': [
        'security/ir.model.access.csv',
        'security/my_module_security.xml',
        'data/my_module_data.xml',
        'views/my_model_views.xml',
        'views/menu_views.xml',
        'report/my_report.xml',
        'wizard/my_wizard_views.xml',
    ],
    'assets': {
        'web.assets_backend': [
            'my_module/static/src/js/my_widget.js',
            'my_module/static/src/css/my_styles.css',
        ],
    },
    'license': 'OPL-1',
    'installable': True,
    'application': False,
    'auto_install': False,
    'price': 249.0,
    'currency': 'USD',
}

バージョン番号付け規則: {odoo_version}.{major}.{minor}.{patch}。新しいモジュールは常に 19.0.1.0.0 から開始してください。


モデルの定義

モデルは Odoo モジュールの中心です。データ構造とビジネス ロジックを定義します。

# models/service_request.py
from odoo import api, fields, models
from odoo.exceptions import ValidationError, UserError

class ServiceRequest(models.Model):
    _name = 'my.service.request'
    _description = 'Service Request'
    _inherit = ['mail.thread', 'mail.activity.mixin']
    _order = 'date_request desc, name'
    _rec_name = 'name'

    name = fields.Char(
        string='Reference',
        required=True,
        copy=False,
        readonly=True,
        default=lambda self: self.env['ir.sequence'].next_by_code('my.service.request')
    )
    state = fields.Selection([
        ('draft', 'Draft'),
        ('submitted', 'Submitted'),
        ('in_progress', 'In Progress'),
        ('done', 'Completed'),
        ('cancelled', 'Cancelled'),
    ], string='Status', default='draft', tracking=True)

    partner_id = fields.Many2one(
        'res.partner', string='Customer',
        required=True, tracking=True,
        domain=[('customer_rank', '>', 0)]
    )
    user_id = fields.Many2one(
        'res.users', string='Assigned To',
        default=lambda self: self.env.user
    )
    date_request = fields.Datetime(
        string='Request Date',
        default=fields.Datetime.now,
        required=True
    )
    date_deadline = fields.Date(string='Deadline')
    description = fields.Html(string='Description')
    priority = fields.Selection([
        ('0', 'Normal'),
        ('1', 'Low'),
        ('2', 'High'),
        ('3', 'Urgent'),
    ], string='Priority', default='0')
    tag_ids = fields.Many2many(
        'my.service.tag', string='Tags'
    )
    line_ids = fields.One2many(
        'my.service.request.line', 'request_id',
        string='Service Lines'
    )
    amount_total = fields.Float(
        string='Total Amount',
        compute='_compute_amount_total',
        store=True
    )
    company_id = fields.Many2one(
        'res.company', string='Company',
        required=True,
        default=lambda self: self.env.company
    )

    @api.depends('line_ids.subtotal')
    def _compute_amount_total(self):
        for request in self:
            request.amount_total = sum(request.line_ids.mapped('subtotal'))

    @api.constrains('date_deadline', 'date_request')
    def _check_deadline(self):
        for record in self:
            if record.date_deadline and record.date_request:
                if record.date_deadline < record.date_request.date():
                    raise ValidationError("Deadline cannot be before the request date.")

    @api.onchange('partner_id')
    def _onchange_partner_id(self):
        if self.partner_id:
            self.user_id = self.partner_id.user_id or self.env.user

    def action_submit(self):
        for record in self:
            if not record.line_ids:
                raise UserError("Cannot submit a request without service lines.")
            record.state = 'submitted'
            record.message_post(body="Service request submitted for processing.")

    def action_start_progress(self):
        self.write({'state': 'in_progress'})

    def action_mark_done(self):
        self.write({'state': 'done'})

    def action_cancel(self):
        for record in self:
            if record.state == 'done':
                raise UserError("Cannot cancel a completed request.")
            record.state = 'cancelled'

    @api.model_create_multi
    def create(self, vals_list):
        for vals in vals_list:
            if vals.get('name', 'New') == 'New':
                vals['name'] = self.env['ir.sequence'].next_by_code(
                    'my.service.request'
                ) or 'New'
        return super().create(vals_list)

サービス リクエスト ライン モデル:

class ServiceRequestLine(models.Model):
    _name = 'my.service.request.line'
    _description = 'Service Request Line'

    request_id = fields.Many2one(
        'my.service.request', string='Request',
        required=True, ondelete='cascade'
    )
    product_id = fields.Many2one(
        'product.product', string='Service',
        required=True,
        domain=[('type', '=', 'service')]
    )
    description = fields.Text(string='Description')
    quantity = fields.Float(string='Quantity', default=1.0)
    price_unit = fields.Float(string='Unit Price')
    subtotal = fields.Float(
        string='Subtotal',
        compute='_compute_subtotal',
        store=True
    )

    @api.depends('quantity', 'price_unit')
    def _compute_subtotal(self):
        for line in self:
            line.subtotal = line.quantity * line.price_unit

    @api.onchange('product_id')
    def _onchange_product_id(self):
        if self.product_id:
            self.price_unit = self.product_id.lst_price
            self.description = self.product_id.description_sale

ビューの作成

ビューは、UI でのレコードの表示方法を定義します。 Odoo は XML を使用してフォーム、リスト、かんばんボードなどを記述します。

<!-- views/service_request_views.xml -->
<odoo>
    <!-- Form View -->
    <record id="view_service_request_form" model="ir.ui.view">
        <field name="name">my.service.request.form</field>
        <field name="model">my.service.request</field>
        <field name="arch" type="xml">
            <form string="Service Request">
                <header>
                    <button name="action_submit" string="Submit"
                            type="object" class="oe_highlight"
                            invisible="state != 'draft'"/>
                    <button name="action_start_progress" string="Start"
                            type="object" class="oe_highlight"
                            invisible="state != 'submitted'"/>
                    <button name="action_mark_done" string="Mark Done"
                            type="object" class="oe_highlight"
                            invisible="state != 'in_progress'"/>
                    <button name="action_cancel" string="Cancel"
                            type="object"
                            invisible="state in ['done', 'cancelled']"/>
                    <field name="state" widget="statusbar"
                           statusbar_visible="draft,submitted,in_progress,done"/>
                </header>
                <sheet>
                    <div class="oe_title">
                        <h1>
                            <field name="name" readonly="1"/>
                        </h1>
                    </div>
                    <group>
                        <group>
                            <field name="partner_id"
                                   options="{'no_create': True}"/>
                            <field name="user_id"/>
                            <field name="priority" widget="priority"/>
                        </group>
                        <group>
                            <field name="date_request"/>
                            <field name="date_deadline"/>
                            <field name="company_id" groups="base.group_multi_company"/>
                        </group>
                    </group>
                    <field name="tag_ids" widget="many2many_tags"/>
                    <notebook>
                        <page string="Service Lines">
                            <field name="line_ids">
                                <tree editable="bottom">
                                    <field name="product_id"/>
                                    <field name="description"/>
                                    <field name="quantity"/>
                                    <field name="price_unit"/>
                                    <field name="subtotal" readonly="1"/>
                                </tree>
                            </field>
                            <group class="oe_subtotal_footer">
                                <field name="amount_total"
                                       widget="monetary"
                                       class="oe_subtotal_footer_separator"/>
                            </group>
                        </page>
                        <page string="Description">
                            <field name="description" widget="html"
                                   placeholder="Detailed description..."/>
                        </page>
                    </notebook>
                </sheet>
                <div class="oe_chatter">
                    <field name="message_follower_ids"/>
                    <field name="activity_ids"/>
                    <field name="message_ids"/>
                </div>
            </form>
        </field>
    </record>

    <!-- List View -->
    <record id="view_service_request_tree" model="ir.ui.view">
        <field name="name">my.service.request.list</field>
        <field name="model">my.service.request</field>
        <field name="arch" type="xml">
            <tree string="Service Requests" decoration-danger="state=='cancelled'"
                  decoration-success="state=='done'">
                <field name="name"/>
                <field name="partner_id"/>
                <field name="user_id" optional="show"/>
                <field name="priority" widget="priority"/>
                <field name="date_request"/>
                <field name="date_deadline" optional="show"/>
                <field name="amount_total" sum="Total"/>
                <field name="state" widget="badge"
                       decoration-info="state=='draft'"
                       decoration-warning="state=='submitted'"
                       decoration-primary="state=='in_progress'"
                       decoration-success="state=='done'"
                       decoration-danger="state=='cancelled'"/>
            </tree>
        </field>
    </record>

    <!-- Search View -->
    <record id="view_service_request_search" model="ir.ui.view">
        <field name="name">my.service.request.search</field>
        <field name="model">my.service.request</field>
        <field name="arch" type="xml">
            <search>
                <field name="name" string="Reference"/>
                <field name="partner_id"/>
                <field name="user_id"/>
                <filter string="My Requests" name="my_requests"
                        domain="[('user_id', '=', uid)]"/>
                <filter string="In Progress" name="in_progress"
                        domain="[('state', '=', 'in_progress')]"/>
                <filter string="Urgent" name="urgent"
                        domain="[('priority', '=', '3')]"/>
                <separator/>
                <filter string="This Month" name="this_month"
                        domain="[('date_request', '&gt;=',
                                  (context_today() - relativedelta(day=1)).strftime('%Y-%m-%d'))]"/>
                <group expand="0" string="Group By">
                    <filter string="Customer" name="group_partner"
                            context="{'group_by': 'partner_id'}"/>
                    <filter string="Status" name="group_state"
                            context="{'group_by': 'state'}"/>
                    <filter string="Assigned To" name="group_user"
                            context="{'group_by': 'user_id'}"/>
                </group>
            </search>
        </field>
    </record>

    <!-- Action -->
    <record id="action_service_request" model="ir.actions.act_window">
        <field name="name">Service Requests</field>
        <field name="res_model">my.service.request</field>
        <field name="view_mode">list,form,kanban</field>
        <field name="search_view_id" ref="view_service_request_search"/>
        <field name="context">{'search_default_in_progress': 1}</field>
    </record>
</odoo>

セキュリティ構成

セキュリティはどの実稼働モジュールにも必須です。

アクセス制御リスト (security/ir.model.access.csv):

id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_service_request_user,service.request.user,model_my_service_request,base.group_user,1,1,1,0
access_service_request_manager,service.request.manager,model_my_service_request,base.group_system,1,1,1,1
access_service_request_line_user,service.request.line.user,model_my_service_request_line,base.group_user,1,1,1,1

レコード ルール (security/my_module_security.xml):

<odoo>
    <!-- Users can only see their own requests unless they're managers -->
    <record id="rule_service_request_own" model="ir.rule">
        <field name="name">Service Request: Own Records</field>
        <field name="model_id" ref="model_my_service_request"/>
        <field name="domain_force">
            [('user_id', '=', user.id)]
        </field>
        <field name="groups" eval="[(4, ref('base.group_user'))]"/>
        <field name="perm_read" eval="True"/>
        <field name="perm_write" eval="True"/>
        <field name="perm_create" eval="True"/>
        <field name="perm_unlink" eval="False"/>
    </record>
</odoo>

ウィザード (TransientModel)

ウィザードは、ガイド付きの複数ステップのアクションのための一時的なフォームです。

# wizard/service_request_wizard.py
from odoo import api, fields, models

class ServiceRequestBulkAssign(models.TransientModel):
    _name = 'my.service.request.bulk.assign'
    _description = 'Bulk Assign Service Requests'

    user_id = fields.Many2one(
        'res.users', string='Assign To', required=True
    )
    request_ids = fields.Many2many(
        'my.service.request', string='Requests',
        default=lambda self: self.env.context.get('active_ids', [])
    )
    note = fields.Text(string='Note')

    def action_assign(self):
        self.request_ids.write({'user_id': self.user_id.id})
        if self.note:
            for request in self.request_ids:
                request.message_post(body=self.note)
        return {'type': 'ir.actions.act_window_close'}

自動化されたアクションとシーケンス

自動番号付けのシーケンス:

<record id="seq_service_request" model="ir.sequence">
    <field name="name">Service Request</field>
    <field name="code">my.service.request</field>
    <field name="prefix">SRQ/%(year)s/</field>
    <field name="padding">5</field>
    <field name="company_id" eval="False"/>
</record>

スケジュールされたアクション (cron ジョブ):

# In the model
def _cron_remind_overdue_requests(self):
    overdue = self.search([
        ('state', 'in', ['submitted', 'in_progress']),
        ('date_deadline', '<', fields.Date.today()),
    ])
    for request in overdue:
        request.activity_schedule(
            'mail.mail_activity_data_warning',
            summary='Overdue Service Request',
            user_id=request.user_id.id
        )
<record id="ir_cron_remind_overdue" model="ir.cron">
    <field name="name">Remind Overdue Service Requests</field>
    <field name="model_id" ref="model_my_service_request"/>
    <field name="state">code</field>
    <field name="code">model._cron_remind_overdue_requests()</field>
    <field name="interval_number">1</field>
    <field name="interval_type">days</field>
    <field name="numbercall">-1</field>
    <field name="active">True</field>
</record>

よくある質問

models.Model、models.TransientModel、models.AbstractModel の違いは何ですか?

models.Model はデータベースに永続テーブルを作成します。 models.TransientModel は、定期的にクリアされる一時テーブルを作成します (ウィザードに使用されます)。 models.AbstractModel はテーブルを作成しません。これは、別のテーブルを作成せずに、他のモデルがメソッドやフィールドを取得するために継承できるミックスインです。

コア コードを変更せずに既存の Odoo モデルを拡張するにはどうすればよいですか?

_inherit を既存のモデル名で使用し、_name を省略します。これにより、フィールドとメソッドが既存のモデル class SaleOrder(models.Model): _inherit = 'sale.order' に追加されます。出発点として他の人の動作をコピーする新しいモデルを作成するには、_name (新しい名前) と _inherit (ソース モデル) の両方を使用します。

モジュールのデータ モデルが変更された場合、移行はどのように処理すればよいですか?

my_module/migrations/{version}/pre-migrate.py または post-migrate.py で移行スクリプトを作成します。これらのスクリプトは、モジュールの更新中に自動的に実行されます。列の名前を変更するには、openupgradelib ヘルパーを使用します。本番環境に適用する前に、必ず本番データベースのコピーで移行をテストしてください。

コア XML ファイルを変更せずに既存の Odoo ビューをオーバーライドできますか?

はい。 inherit_id を使用して拡張するビューを参照し、xpath 式を使用して要素を見つけ、position 属性 (前、後、内部、置換、属性) を使用して変更を指定します。これにより、変更が分離され、安全にアップグレードできます。

複数の会社が存在する環境でフィールドを会社固有にするにはどうすればよいですか?

フィールド定義: my_field = fields.Char(company_dependent=True)company_dependent=True を使用します。これにより、会社ごとに個別の値が保存されるため、会社 A と会社 B が同じレコードに対して異なる値を持つことができます。これは、価格表、税金口座、その他の会社固有の構成に使用されます。

開発中にメッセージをログに記録し、デバッグする正しい方法は何ですか?

Python の logging モジュール: import logging; _logger = logging.getLogger(__name__) を使用します。さまざまな重大度レベルには _logger.info()_logger.warning()_logger.error() を使用します。製品コードでは print() ステートメントを決して使用しないでください。開発中は、--log-level=debug を指定して Odoo を実行して、すべてのデバッグ出力を確認します。


次のステップ

本番環境に対応した Odoo モジュールを構築するには、フレームワーク、PostgreSQL のパフォーマンスに関する考慮事項、アップグレードしても安全なパターン、および徹底的なテストに関する深い知識が必要です。 Odoo マーケットプレイス向けのモジュールは、セキュリティ、パフォーマンス、コード品質について追加の検証を受けます。

ECOSIRE は、特殊な業界ワークフローからマーケットプレイス コネクタ モジュールに至るまで、特定のビジネス要件に合わせてカスタム Odoo 19 Enterprise モジュールを開発します。私たちの開発チームは、Odoo の公式コーディング ガイドラインに従っており、包括的な単体テストが含まれており、完全なドキュメントを備えたモジュールを提供しています。

ECOSIRE からカスタム Odoo モジュールをコミッショニング →

要件を共有していただければ、開発作業の範囲を絞り、タイムラインを提供し、Odoo 19 Enterprise インストールときれいに統合されるモジュールを提供します。

E

執筆者

ECOSIRE Research and Development Team

ECOSIREでエンタープライズグレードのデジタル製品を開発。Odoo統合、eコマース自動化、AI搭載ビジネスソリューションに関するインサイトを共有しています。

WhatsAppでチャット