Zum Hauptinhalt springen
Odoo 19 · Installation · Modulentwicklung

Ein Modul, das in Odoo 19 kompiliert und sich trotzdem nicht installieren lässt

Kompilieren ist nicht installieren, und eine Installation in einer sauberen Datenbank ist kein Update einer Datenbank, die noch die Vorgängerversion hatte. Das sind drei verschiedene Prüfungen, und fast alle machen nur die erste.

Wenn Sie hier gelandet sind, hat Ihnen der Build etwas wie ValueError: Invalid field 'category_id' in 'res.groups' ausgegeben, während lokal alles lief. Das liegt nicht an Ihrer Umgebung. Der Linter, der import und die Unit-Tests führen genau den Teil von Odoo nicht aus, der scheitert: die Registry, die bei der Installation aufgebaut wird.

Das Feld «Was ändert sich in Odoo 19» ist längst gut beackert. Hier geht es um die nächste Stufe: Das Modul besteht den Linter, lässt sich fehlerfrei importieren, und trotzdem scheitert -i. Oder schlimmer: Es installiert sich und verhält sich still falsch.

Was Ihre CI prüft und was nicht

python -m compileall prüft die Python-Syntax. xmllint --noout prüft, ob das XML wohlgeformt ist. Eine Suite «reiner» Tests ohne laufendes Odoo prüft Ihre Logik. Keine der drei öffnet die Odoo-Registry, und genau dort sitzt der Fehler.

Installieren ist etwas anderes: Odoo baut die Registry auf, und jedes <record> landet in einem create() oder write() des ORM. Außerdem validiert write() die Feldnamen einzeln: In odoo/orm/models.py von 19.0 erscheint ein KeyError auf self._fields[field_name] als ValueError: Invalid field 'category_id' in 'res.groups'. Das ist kein Syntaxfehler, sondern ein Laufzeitfehler des XML.

Und die größte Falle, die ein Gate zur Show macht: Ein -i auf ein bereits installiertes Modul tut nichts. In odoo/modules/loading.py von 19.0 ist update_operation nur dann 'install', wenn der Status to install ist; ist er installed, ist der Wert None. Und mit None werden, sobald in der Befehlszeile ein -i oder ein -u steht, weder die Daten geladen noch der Testblock betreten. Build grün, null Tests. Bevor Sie einem Gate glauben, schauen Sie, wie viele Tests es angeblich ausgeführt hat.

res.groups.privilege ist kein umbenanntes Feld: Es ist ein neues Modell

Die Migrationsanleitungen enthalten eine Tabelle mit Umbenennungen, und damit beheben Sie die Hälfte der Fälle. category_id steht nicht darin, weil Odoo 19 es nicht umbenannt hat: Es hat es aus res.groups herausgenommen. Ein Suchen-und-Ersetzen hat kein Ziel.

In 17 und 18 verweist res.groups.category_id auf ir.module.category. In 19 gibt es ein neues Zwischenmodell, res.groups.privilege (odoo/addons/base/models/res_groups_privilege.py, vierzehn Zeilen): Die Gruppe trägt privilege_id, und der Privilege trägt category_id. Nebenbei führt 19 UNIQUE (privilege_id, name) ein: Zwei Gruppen namens «Manager» unter demselben Privilege lassen sich nicht installieren.

Die Lösung besteht nicht darin, einen Namen zu ändern, sondern darin, einen Datensatz eines Modells anzulegen, das es in Ihrer Ausgangsversion nicht gab. So sieht es in unseren Modulen aus, mit dem Kommentar, den wir in der Datei hinterlassen, damit es nicht noch einmal passiert:

<record id="module_category_mi_modulo" model="ir.module.category">
  <field name="name">Mi modulo</field>
</record>

<!-- Odoo 19 moved the grouping of access groups out of res.groups: a group
     now points at a res.groups.privilege, and the privilege is what carries
     the module category. Setting category_id on res.groups raises
     "Invalid field 'category_id' in 'res.groups'". -->
<record id="privilege_mi_modulo" model="res.groups.privilege">
  <field name="name">Mi modulo</field>
  <field name="category_id" ref="module_category_mi_modulo"/>
</record>

<record id="group_mi_modulo_user" model="res.groups">
  <field name="name">User</field>
  <field name="privilege_id" ref="privilege_mi_modulo"/>
</record>

Drumherum gibt es echte Umbenennungen, und die behebt tatsächlich ein Ersetzen: res.groups.users wird zu user_ids, res.users.groups_id zu group_ids, ebenso bei ir.actions.act_window und ir.actions.server. Was sich nicht geändert hat, sind die Tabellen: res_groups_users_rel(gid, uid) und res_groups_implied_rel(gid, hid) sind in 17, 18 und 19 identisch. Das SQL einer Migration übersteht alle drei Serien; der ORM-Code drumherum nicht.

base.group_system mit noupdate: Es installiert sich, und die Berechtigungen bleiben, wie sie waren

Die übliche Redewendung, damit der Administrator Ihre Gruppe erbt, ist ein <record> auf eine fremde xmlid:

<record id="base.group_system" model="res.groups">
  <field name="implied_ids" eval="[(4, ref('group_mi_manager'))]"/>
</record>

Das wird bei einer Neuinstallation angewendet. Bei einem Update ignoriert Odoo es stillschweigend. Die Regel steht in _load_records (odoo/orm/models.py) und passt in eine Zeile: if not (update and d_noupdate): to_update.append(data). Entscheidend ist nicht Ihre Datei, sondern die Spalte noupdate der Zeile, die in ir_model_data für diese xmlid bereits existiert. Nehmen Sie das in keine Richtung als gegeben an: Fragen Sie Ihre Datenbank.

SELECT module, name, noupdate FROM ir_model_data
 WHERE module = 'base' AND name IN ('group_system', 'group_user');

In unserer Odoo.sh-Produktion, geprüft am 3. September 2026, kommen beide Zeilen mit noupdate = t zurück. Mit diesem Wert wird das obige XML bei einem Upgrade nie erneut angewendet.

Warum das wichtig ist: Setzen Sie zusätzlich groups="mi_modulo.group_manager" auf ein Zugangsdatenfeld, bleibt diese Gruppe in einer bereits installierten Datenbank ohne jedes Mitglied, und niemand (auch nicht der Administrator) kann dieses Feld lesen oder schreiben. Der interne Code mit sudo() funktioniert weiter, deshalb sieht kein Test das Problem: Die Tests laufen auf einer sauberen Installation, wo das <record> angewendet wurde. Genau so ist es uns passiert.

Das Rezept: das XML belassen, das Neuinstallationen abdeckt, und eine migrations/<serie>.<version>/post-migrate.py-Migration ergänzen, die es als idempotentes SQL auf res_groups_implied_rel und res_groups_users_rel schreibt: implied_ids überträgt nur über den ORM auf die Benutzer, daher müssen Sie sie ebenfalls einfügen. Dazu ein Test, der fehlschlägt, wenn die Gruppe leer bleibt. Die transitive Hülle ist in 19 all_implied_ids und in 17 und 18 trans_implied_ids.

ir.cron ohne numbercall: Der Cron läuft einmal und schaltet sich selbst ab

Dieser Fehler zeigt sich erst in der Woche danach. In Odoo 17 deklariert odoo/addons/base/models/ir_cron.py den Wert numbercall = fields.Integer(..., default=1, ...). Ein <record model="ir.cron">, der ihn nicht setzt, erbt diese 1. Nach dem ersten Lauf ergibt call_count_left 0, und das UPDATE, das den Lauf abschließt, schreibt active = job['active'] and bool(call_count_left): falsch. Falls noch Zweifel bestehen: Die Abfrage, die Jobs auswählt, filtert nach AND numbercall != 0.

Ergebnis: Der Cron läuft einmal, deaktiviert sich selbst und hinterlässt keine Spur. Keine Exception, keine Logzeile, nichts in der Oberfläche. Es sieht genau so aus wie «der Cron springt nicht an». Die Lösung ist <field name="numbercall">-1</field>, wobei negativ für «ohne Limit» steht.

Und hier die unangenehme Symmetrie: Odoo 18 hat das Feld entfernt, und 19 hatte es nie. numbercall in 19 zu setzen verhindert die Installation (Invalid field 'numbercall' in 'ir.cron'); es in 17 wegzulassen bricht die Funktion, ohne etwas zu melden. Dieselbe Datei scheitert in zwei entgegengesetzten Richtungen. Mit doall ist es genauso.

Diesen Fehler machen auch wir selbst. Bei der Prüfung unseres Katalogs fanden wir vierzehn ir.cron-Datensätze in vier auf 17 portierten Konnektoren ohne ein einziges numbercall; als wir die Suche auf den 17.0-Zweig des tatsächlich ausgeführten Deployments ausdehnten, 130 Datensätze in fünfzehn Modulen, keiner mit dem Feld. Nüchtern gezählt: Wir haben es gefunden, weil wir gezielt mit einem grep danach gesucht haben, nicht weil es aufgefallen wäre.

Das Inventar, und in welcher Version welche Falle zubeißt

Das haben wir gesammelt, indem wir mit unserem eigenen Katalog tatsächlich auf Odoo.sh in allen drei Serien installiert haben. Keine dieser Fallen fängt ein Compiler oder Linter. Die mit «silencioso» markierten Zeilen sind schlimmer: Das Modul installiert sich, und der Build wird grün.

Was Sie schreibenWas passiertWo es zubeißt
category_id in res.groupsInvalid field 'category_id' in 'res.groups'Keine Installation · 19
users oder groups_idUmbenannt in user_ids und group_idsKeine Installation · 19
numbercall oder doall in ir.cronInvalid field 'numbercall' in 'ir.cron'Keine Installation · 18 und 19
fehlendes numbercall in ir.cronInstalliert sich; der Cron läuft einmal und schaltet sich abStill · nur 17
_sql_constraints in einem Modell von 19Installiert sich; eine Warnung im Log, und die Einschränkung wird nicht angelegtStill · 19
models.Constraint in 18 oder 17module 'odoo.models' has no attribute 'Constraint'Keine Installation · 18 und 17
<record id="base.group_system">Installiert sich; bei einem Upgrade wird es nicht erneut angewendetStill · alle drei
<tree> in einer VererbungDer xpath trifft nicht, und die Ansicht brichtKeine Installation · 18 und 19
In einem Modifier zitiertes, aber nicht gerendertes Feldmust be present in view but is missingKeine Installation · nur 17

Beachten Sie die letzte Zeile: Diese Prüfung macht nur 17. Die Meldung steht zwar weiter im Code von 18 und 19, deckt dort aber nur Namen und Aktionen ab, nicht ein in einem Modifier zitiertes Feld. Wenn Sie gegen 19 entwickeln und rückwärts portieren, bricht die Ansicht, die bei Ihnen einwandfrei installiert, in 17. Die Fallen gehen nicht alle in dieselbe Richtung.

So erkennen Sie das wirklich: zweimal installieren, in zwei verschiedenen Datenbanken

Test A: saubere Datenbank, -i. Er erfasst alles, was die Installation verhindert: nicht mehr existierende Felder, nicht passende xpath, ungültige Ansichten, Constraints, die die Datenbank ablehnt. Er erfasst nichts von dem, was nur beim Update passiert: Hier ist update falsch, die noupdate werden ignoriert, und die Skripte von migrations/ laufen gar nicht, denn migrate_module (odoo/modules/migration.py) steigt aus, wenn das Modul nicht in to upgrade steht.

Test B: Datenbank mit der Vorgängerversion, -u. Er erfasst, was nur beim Update auftaucht: Datensätze, die nicht erneut angewendet werden, neue unbefüllte Spalten, Migrationen, die mittendrin scheitern, alte Daten, die nicht mehr passen. Hier laufen Ihre pre-migrate und post-migrate tatsächlich, und nur so wissen Sie, ob sie funktionieren.

Keiner ersetzt den anderen, und wenn Sie für 17, 18 und 19 veröffentlichen, sind es sechs Installationen, nicht zwei. Ein praktischer Hinweis: Setzen Sie vor dem -i mit einer echten Deinstallation über den ORM zurück. Den Status von Hand mit psql zu ändern, hinterlässt einen inkonsistenten Graphen, und der Loader überspringt das Modul stillschweigend: wieder ein grüner Build, der nichts getestet hat.

Die Checkliste vor dem Hochladen

  • grep von category_id in jedem <record model="res.groups">. Taucht eines auf, muss ein res.groups.privilege angelegt werden.
  • grep von groups_id und von users im XML: In 19 sind es group_ids und user_ids.
  • grep von numbercall und doall: in 18 und 19 raus, in 17 bis -1 Pflicht.
  • grep von _sql_constraints in 19: Es bleibt bei einer Warnung im Log, und die Einschränkung wird nicht angelegt. Und von models.Constraint in 18 und 17: Dieser Name existiert dort nicht, und das Modul lässt sich nicht einmal importieren.
  • grep von <tree in 18/19 und von <list in 17, ohne das view_mode der Aktionen zu vergessen.
  • Für jedes <record id="base....">: Gibt es eine Migration für das Upgrade und einen Test, der fehlschlägt, wenn die Gruppe leer bleibt?
  • In einer sauberen Datenbank installieren. Die ausgeführten Tests zählen. Steht dort null, ist das Gate leer.
  • Auf einer Datenbank mit der Vorgängerversion installieren. Die Tests erneut zählen.
  • In jeder Serie wiederholen, die Sie veröffentlichen wollen.

Die ersten fünf Punkte sind ein statisches Skript, das in einer Sekunde läuft; der echte Build dauert Minuten. Machen Sie zuerst den günstigen, aber lassen Sie ihn den teuren nicht ersetzen: Der grep findet, wonach Sie schon zu suchen wissen, und der Build findet, wonach nicht.

Wenn Sie aus einer Migration kommen, finden Sie die vorherige Stufe unter häufige Fehler bei der Migration auf Odoo 19.

Die Grundregel, die einzige, die Sie mitnehmen sollten: Kompilieren, Parsen und grüne Tests sind nicht dasselbe wie Installieren. Die beiden Module, die uns dieses Jahr am teuersten zu stehen kamen, hatten lokal alles grün und ließen sich nicht installieren.

Engineering für Odoo-PartnerEigenes Produkt, das in 17, 18 und 19 installiert, mit echtem Gate in jeder Serie.Migration auf Odoo 19Was zu prüfen ist, in welcher Reihenfolge und was eine Migration teurer macht.Migrationsfehler bei Odoo 19Die vorherige Stufe: was bricht, bevor Sie den Build erreichen.Was Odoo 19 bringtDie funktionalen Änderungen, um zu entscheiden, ob sich der Wechsel lohnt.

Häufige Fragen

Warum laufen meine Tests durch, wenn sich das Modul nicht installieren lässt?

Weil eine reine Python-Suite die Odoo-Registry nicht öffnet: Sie validiert Ihre Logik, nicht Ihre Datendateien. Und ein -i auf ein bereits installiertes Modul verarbeitet nichts: Der Testblock wird gar nicht ausgeführt, und der Build wird grün mit null Tests.

Muss ich res.groups.privilege auch in 17 und 18 anlegen?

Nein: Dieses Modell gibt es vor 19 nicht. In 17 und 18 steht die Kategorie in der Gruppe selbst, mit category_id. Das ist einer der Gründe, warum das Sicherheits-XML in den drei Serien nicht dieselbe Datei sein kann.

Kann ich numbercall setzen, damit es in allen drei Versionen gilt?

Nein. In 18 und 19 existiert das Feld nicht, und sein Vorhandensein verhindert die Installation des Moduls. Und es in 17 zu entfernen führt dazu, dass sich der Cron nach dem ersten Lauf selbst abschaltet. Die Datei muss pro Serie erzeugt werden.

Reicht es nicht, in einer sauberen Datenbank zu installieren, wenn das klappt?

Nein, denn bei einer sauberen Installation werden die Skripte von migrations/ nicht ausgeführt, und die mit noupdate markierten Datensätze werden angewendet. Genau die zwei Verhaltensweisen, die beim Update scheitern, fallen aus diesem Test heraus.

Ihr Modul lässt sich nicht installieren, und Sie wissen nicht, warum?

Wir prüfen Ihr Sicherheits-XML, Ihre Crons, Ihre vererbten Ansichten und Ihre Migrationsskripte und sagen Ihnen, was in jeder Serie bricht, bevor es ein Kunde entdeckt. Buchen Sie einen Anruf über Calendly oder rufen Sie uns an unter +34 616 809 504.

Mit einem Ingenieur sprechen