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
|19. März 20268 Min. Lesezeit1.7k Wörter|

Erstellen benutzerdefinierter Odoo-Module: Entwickler-Tutorial

Das Modulsystem von Odoo ist eines der leistungsstärksten Erweiterungsframeworks in der ERP-Welt. Jede Funktion in Odoo – von der Buchhaltung über das Inventar bis hin zum CRM – ist ein Modul. Das bedeutet, dass die Erstellung benutzerdefinierter Funktionen genau den gleichen Mustern folgt, die von Odoos eigenen Entwicklern verwendet werden, sodass Sie Zugriff auf das gesamte Framework haben, ohne den Kern zu forken.

Dieses Tutorial deckt den gesamten Lebenszyklus eines benutzerdefinierten Odoo 19-Moduls ab: vom Gerüstbau der Verzeichnisstruktur und der Definition von Modellen bis hin zur Erstellung von Ansichten, der Sicherung des Zugriffs und der Bereitstellung in der Produktion. Am Ende verfügen Sie über ein Arbeitsmodul, das den Konventionen von Odoo 19 Enterprise folgt und für den Markt bereit ist.

Wichtige Erkenntnisse

  • Jedes Odoo-Modul ist ein Python-Paket mit einem __manifest__.py-Deskriptor – Modelle erben von models.Model und werden direkt auf PostgreSQL-Tabellen abgebildet – Ansichten werden in XML definiert und referenzieren Modellfelder nach Namen – Die Sicherheit wird durch ir.model.access CSV- und Aufzeichnungsregeln erzwungen
  • Assistenten (TransientModel) verarbeiten mehrstufige Benutzerinteraktionen – Berechnete Felder und Onchange-Methoden aktualisieren verwandte Felder dynamisch – Automatisierte Aktionen und geplante Jobs führen serverseitige Logik auf Trigger aus
  • Modulabhängigkeiten stellen die korrekte Ladereihenfolge und Funktionsverfügbarkeit sicher

Modulstruktur und Gerüstbau

Jedes Odoo-Modul ist ein Verzeichnis mit einer bestimmten Struktur. Verwenden Sie den integrierten Scaffold-Befehl von Odoo, um das Boilerplate zu generieren:

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

Dadurch entsteht:

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/

Die Manifestdatei (__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',
}

Versionsnummerierungskonvention: {odoo_version}.{major}.{minor}.{patch}. Beginnen Sie bei neuen Modulen immer bei 19.0.1.0.0.


Modelle definieren

Modelle sind das Herzstück jedes Odoo-Moduls. Sie definieren die Datenstruktur und Geschäftslogik.

# 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)

Service Request Line-Modell:

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

Ansichten erstellen

Ansichten definieren, wie Datensätze in der Benutzeroberfläche angezeigt werden. Odoo verwendet XML zur Beschreibung von Formularen, Listen, Kanban-Boards und mehr.

<!-- 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>

Sicherheitskonfiguration

Sicherheit ist für jedes Produktionsmodul obligatorisch.

Zugriffskontrollliste (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

Aufzeichnungsregeln (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>

Assistenten (TransientModel)

Assistenten sind temporäre Formulare für geführte mehrstufige Aktionen.

# 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'}

Automatisierte Aktionen und Sequenzen

Reihenfolge für die automatische Nummerierung:

<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>

Geplante Aktion (Cron-Job):

# 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>

Häufig gestellte Fragen

Was ist der Unterschied zwischen models.Model, models.TransientModel und models.AbstractModel?

models.Model erstellt eine permanente Tabelle in der Datenbank. models.TransientModel erstellt eine temporäre Tabelle, die regelmäßig gelöscht wird (wird für Assistenten verwendet). models.AbstractModel erstellt keine Tabelle – es ist ein Mixin, das andere Modelle erben können, um Methoden und Felder zu erhalten, ohne eine separate Tabelle zu erstellen.

Wie erweitere ich ein vorhandenes Odoo-Modell, ohne den Kerncode zu ändern?

Verwenden Sie _inherit mit dem vorhandenen Modellnamen und lassen Sie _name weg. Dadurch werden Ihre Felder und Methoden zum vorhandenen Modell hinzugefügt: class SaleOrder(models.Model): _inherit = 'sale.order'. Um ein neues Modell zu erstellen, das das Verhalten eines anderen als Ausgangspunkt kopiert, verwenden Sie sowohl _name (neuer Name) als auch _inherit (Quellmodell).

Wie soll ich mit Migrationen umgehen, wenn sich das Datenmodell meines Moduls ändert?

Erstellen Sie ein Migrationsskript in my_module/migrations/{version}/pre-migrate.py oder post-migrate.py. Diese Skripte werden während der Modulaktualisierung automatisch ausgeführt. Verwenden Sie für Spaltenumbenennungen openupgradelib-Helfer. Testen Sie Migrationen immer an einer Kopie der Produktionsdatenbank, bevor Sie sie in der Produktion anwenden.

Kann ich vorhandene Odoo-Ansichten überschreiben, ohne die Kern-XML-Dateien zu ändern?

Ja. Verwenden Sie inherit_id, um auf die Ansicht zu verweisen, die Sie erweitern möchten, und verwenden Sie dann xpath-Ausdrücke, um das Element zu lokalisieren, und position-Attribute (vorher, nachher, innerhalb, ersetzen, Attribute), um die Änderung anzugeben. Dadurch bleiben Ihre Änderungen isoliert und vor Upgrades geschützt.

Wie mache ich ein Feld in einer Umgebung mit mehreren Unternehmen unternehmensspezifisch?

Verwenden Sie company_dependent=True für die Felddefinition: my_field = fields.Char(company_dependent=True). Dadurch wird pro Unternehmen ein separater Wert gespeichert, sodass Unternehmen A und Unternehmen B unterschiedliche Werte für denselben Datensatz haben können. Dies wird für Preislisten, Steuerkonten und andere unternehmensspezifische Konfigurationen verwendet.

Wie werden Meldungen während der Entwicklung korrekt protokolliert und Fehler behoben?

Verwenden Sie das logging-Modul von Python: import logging; _logger = logging.getLogger(__name__). Verwenden Sie _logger.info(), _logger.warning(), _logger.error() für unterschiedliche Schweregrade. Verwenden Sie niemals print()-Anweisungen im Produktionscode. Führen Sie in der Entwicklung Odoo mit --log-level=debug aus, um alle Debug-Ausgaben anzuzeigen.


Nächste Schritte

Der Aufbau eines produktionsreifen Odoo-Moduls erfordert umfassende Kenntnisse des Frameworks, Überlegungen zur PostgreSQL-Leistung, upgradesichere Muster und gründliche Tests. Module, die für den Odoo-Marktplatz bestimmt sind, werden einer zusätzlichen Validierung auf Sicherheit, Leistung und Codequalität unterzogen.

ECOSIRE entwickelt maßgeschneiderte Odoo 19 Enterprise-Module für spezifische Geschäftsanforderungen – von speziellen Branchen-Workflows bis hin zu Marktplatz-Connector-Modulen. Unser Entwicklungsteam befolgt die offiziellen Codierungsrichtlinien von Odoo, umfasst umfassende Unit-Tests und liefert Module mit vollständiger Dokumentation.

Beauftragen Sie ein benutzerdefiniertes Odoo-Modul bei ECOSIRE →

Teilen Sie uns Ihre Anforderungen mit und wir grenzen den Entwicklungsaufwand ab, stellen einen Zeitplan bereit und liefern ein Modul, das sich nahtlos in Ihre Odoo 19 Enterprise-Installation integrieren lässt.

E

Geschrieben von

ECOSIRE Research and Development Team

Entwicklung von Enterprise-Digitalprodukten bei ECOSIRE. Einblicke in Odoo-Integrationen, E-Commerce-Automatisierung und KI-gestützte Geschäftslösungen.

Chatten Sie auf WhatsApp