Als je hier bent beland, heeft de build iets als ValueError: Invalid field 'category_id' in 'res.groups' uitgespuwd en gebeurde er lokaal niets. Het ligt niet aan je omgeving. De linter, de import en de unit tests voeren het deel van Odoo dat faalt niet uit: de registry die tijdens de installatie wordt opgebouwd.
Over "wat verandert er in Odoo 19" is al genoeg geschreven. Dit gaat over de volgende stap: de module haalt de linter, importeert zonder fout en toch faalt de -i. Of erger: ze installeert en gedraagt zich stilzwijgend verkeerd.
Wat je CI controleert en wat niet
python -m compileall controleert Python-syntaxis. xmllint --noout controleert of de XML goed gevormd is. Een suite "pure" tests, zonder draaiende Odoo, controleert je logica. Geen van de drie opent de Odoo-registry, en daar zit de fout.
Installeren is iets anders: Odoo bouwt de registry op en elke <record> eindigt in een create() of write() van de ORM. En write() valideert de veldnamen één voor één: in odoo/orm/models.py van 19.0 komt een KeyError op self._fields[field_name] uit als ValueError: Invalid field 'category_id' in 'res.groups'. Het is geen syntaxisfout; het is een uitvoeringsfout van de XML.
En de grootste valkuil, die van een gate een schijnvertoning maakt: een -i op een al geïnstalleerde module doet niets. In odoo/modules/loading.py van 19.0 is update_operation alleen 'install' als de status to install is; is die installed, dan is het None. En met None, wanneer er in de opdrachtregel een -i of een -u staat, worden de data niet geladen en wordt het testblok niet betreden. Groene build, nul tests. Kijk, voordat je een gate gelooft, hoeveel tests hij zegt te hebben uitgevoerd.
res.groups.privilege is geen hernoemd veld: het is een nieuw model
De migratiegidsen bevatten een tabel met hernoemingen en daarmee los je de helft van de gevallen op. category_id staat er niet in, want Odoo 19 heeft het niet hernoemd: het heeft het uit res.groups gehaald. Zoeken-en-vervangen heeft niets om op te richten.
In 17 en 18 verwijst res.groups.category_id naar ir.module.category. In 19 is er een nieuw tussenliggend model, res.groups.privilege (odoo/addons/base/models/res_groups_privilege.py, veertien regels): de groep draagt privilege_id en het privilege draagt category_id. Daarnaast voegt 19 UNIQUE (privilege_id, name) toe: twee groepen met de naam "Manager" onder hetzelfde privilege installeren niet.
De oplossing is niet een naam wijzigen: het is een record aanmaken van een model dat in je oorspronkelijke versie niet bestond. Zo ziet het eruit in onze modules, met de opmerking die we in het bestand hebben gezet zodat het niet opnieuw gebeurt:
<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>
Eromheen zijn er wel echte hernoemingen, en die los je wel op met een vervanging: res.groups.users wordt user_ids, res.users.groups_id wordt group_ids, en hetzelfde in ir.actions.act_window en ir.actions.server. Wat niet veranderde zijn de tabellen: res_groups_users_rel(gid, uid) en res_groups_implied_rel(gid, hid) zijn identiek in 17, 18 en 19. De SQL van een migratie overleeft alle drie de series; de ORM-code eromheen niet.
base.group_system met noupdate: installeert goed en de rechten blijven zoals ze waren
Het gebruikelijke idioom om de beheerder jouw groep te laten erven is een <record> op een vreemde xmlid:
<record id="base.group_system" model="res.groups">
<field name="implied_ids" eval="[(4, ref('group_mi_manager'))]"/>
</record>
Dat wordt toegepast bij een nieuwe installatie. Bij een update negeert Odoo het stilzwijgend. De regel staat in _load_records (odoo/orm/models.py) en past op één regel: if not (update and d_noupdate): to_update.append(data). Wat beslist is niet je bestand, maar de kolom noupdate van de rij die al bestaat in ir_model_data voor die xmlid. Ga er in geen enkele richting van uit: vraag het aan je database.
SELECT module, name, noupdate FROM ir_model_data
WHERE module = 'base' AND name IN ('group_system', 'group_user');
In onze Odoo.sh-productie, gecontroleerd op 3 september 2026, komen beide rijen terug met noupdate = t. Met die waarde wordt de XML hierboven bij een upgrade nooit opnieuw toegepast.
Waarom het ertoe doet: als je daarnaast groups="mi_modulo.group_manager" zet op een veld met inloggegevens, blijft die groep in een al geïnstalleerde database zonder enig lid en kan niemand (ook de beheerder niet) dat veld lezen of schrijven. De interne code met sudo() blijft werken, dus geen enkele test ziet het: de tests draaien op een schone installatie, waar de <record> wel is toegepast. Zo is het ons precies overkomen.
Het recept: de XML laten staan, die dekt nieuwe installaties, en een migrations/<serie>.<version>/post-migrate.py-migratie toevoegen die het in idempotente SQL wegschrijft op res_groups_implied_rel en res_groups_users_rel: implied_ids propageert alleen naar de gebruikers via de ORM, dus je moet ze ook invoegen. En een test die faalt als de groep leeg blijft. De transitieve sluiting is all_implied_ids in 19 en trans_implied_ids in 17 en 18.
ir.cron zonder numbercall: de cron draait één keer en zet zichzelf uit
Deze zie je pas de week erna. In Odoo 17 declareert odoo/addons/base/models/ir_cron.py numbercall = fields.Integer(..., default=1, ...). Een <record model="ir.cron"> die dit niet invult, erft die 1. Na de eerste uitvoering geeft call_count_left 0 en de UPDATE die de uitvoering afsluit schrijft active = job['active'] and bool(call_count_left): false. Voor de zekerheid: de query die taken selecteert filtert op AND numbercall != 0.
Resultaat: de cron draait één keer, schakelt zichzelf uit en laat geen spoor na. Geen uitzondering, geen logregel, niets in de interface. Het lijkt precies op "de cron start niet". De oplossing is <field name="numbercall">-1</field>, negatief voor "zonder limiet".
En hier de onaangename symmetrie: Odoo 18 verwijderde het veld en 19 heeft het nooit gehad. numbercall opnemen in 19 verhindert de installatie (Invalid field 'numbercall' in 'ir.cron'); het weglaten in 17 breekt de werking zonder iets te zeggen. Hetzelfde bestand faalt in twee tegengestelde richtingen. Met doall is het hetzelfde.
Deze fout maken we ook zelf. Bij de audit van onze catalogus vonden we veertien ir.cron-records in vier connectoren die naar 17 zijn geporteerd zonder een enkele numbercall; toen we de zoektocht uitbreidden naar de 17.0-branch van de deployment die echt draait, 130 records in vijftien modules, geen enkele met het veld. Eerlijk geteld: we vonden het omdat we ernaar zochten met een grep, niet omdat het afging.
De inventaris, en in welke versie elke valkuil bijt
Dit hebben we verzameld door echt te installeren op Odoo.sh in de drie series, met onze eigen catalogus. Geen van deze valkuilen wordt gevangen door een compiler of linter. De rijen gemarkeerd als "stil" zijn erger: de module installeert en de build is groen.
| Wat je schrijft | Wat er gebeurt | Waar het bijt |
|---|---|---|
category_id in res.groups | Invalid field 'category_id' in 'res.groups' | Installeert niet · 19 |
users of groups_id | Hernoemd naar user_ids en group_ids | Installeert niet · 19 |
numbercall of doall in ir.cron | Invalid field 'numbercall' in 'ir.cron' | Installeert niet · 18 en 19 |
ontbrekende numbercall in ir.cron | Installeert; de cron draait één keer en zet zichzelf uit | Stil · alleen 17 |
_sql_constraints in een model van 19 | Installeert; een warning in het log en de constraint wordt niet aangemaakt | Stil · 19 |
models.Constraint in 18 of 17 | module 'odoo.models' has no attribute 'Constraint' | Installeert niet · 18 en 17 |
<record id="base.group_system"> | Installeert; bij een upgrade niet opnieuw toegepast | Stil · alle drie |
<tree> in een overerving | De xpath past niet en de view crasht | Installeert niet · 18 en 19 |
| Veld genoemd in een modifier en niet gerenderd | must be present in view but is missing | Installeert niet · alleen 17 |
Let op de laatste rij: die controle doet alleen 17. Het bericht staat nog in de code van 18 en 19, maar dekt daar alleen namen en acties, niet een veld dat in een modifier wordt genoemd. Als je tegen 19 ontwikkelt en terugporteert, breekt de view die bij jou perfect installeert in 17. De valkuilen gaan niet allemaal dezelfde kant op.
Hoe je dit echt opspoort: twee keer installeren, op twee verschillende databases
Test A: lege database, -i. Vangt alles wat de installatie verhindert: velden die niet meer bestaan, xpaths die niet passen, ongeldige views, constraints die de database afwijst. Vangt niets van wat alleen bij een update gebeurt: hier is update false, worden de noupdate genegeerd en draaien de scripts van migrations/ niet, omdat migrate_module (odoo/modules/migration.py) vertrekt als de module niet in to upgrade staat.
Test B: database met de vorige versie, -u. Vangt wat alleen bij een update opduikt: records die niet opnieuw worden toegepast, nieuwe kolommen zonder waarde, migraties die halverwege falen, oude data die niet meer passen. Hier draaien je pre-migrate en post-migrate wel, en het is de enige manier om te weten of ze werken.
Geen van beide vervangt de andere, en als je voor 17, 18 en 19 publiceert zijn het zes installaties, geen twee. Een praktisch detail: reset vóór de -i met een echte de-installatie via de ORM. De status met de hand wijzigen met psql laat de graaf inconsistent en de loader slaat de module stilzwijgend over: opnieuw een groene build die niets heeft getest.
De checklist voor je uploadt
grepvancategory_idin elke<record model="res.groups">. Komt er een voor, dan moet er eenres.groups.privilegeworden aangemaakt.grepvangroups_iden vanusersin de XML: in 19 zijn hetgroup_idsenuser_ids.grepvannumbercallendoall: weg in 18 en 19, verplicht met-1in 17.grepvan_sql_constraintsin 19: het blijft bij een warning in het log en de constraint wordt niet aangemaakt. En vanmodels.Constraintin 18 en 17: die naam bestaat daar niet en de module wordt niet eens geïmporteerd.grepvan<treein 18/19 en van<listin 17, zonder deview_modevan de acties te vergeten.- Per
<record id="base....">: is er een migratie voor de upgrade en een test die faalt als de groep leeg blijft? - Installeren op een lege database. Het aantal uitgevoerde tests tellen. Als er nul staat, is de gate leeg.
- Installeren op een database met de vorige versie. De tests opnieuw tellen.
- Herhalen in elke serie die je gaat publiceren.
De eerste vijf punten zijn een statisch script dat in een seconde draait; de echte build duurt minuten. Doe eerst de goedkope, maar laat hem de dure niet vervangen: de grep vindt wat je al weet te zoeken, en de build vindt wat je niet weet.
Kom je van een migratie, dan staat het vorige niveau in de veelgemaakte fouten bij het migreren naar Odoo 19.
De basisregel, de enige om mee te nemen: compileren, parsen en groene tests zijn niet hetzelfde als installeren. De twee modules die ons dit jaar het duurst zijn uitgekomen, hadden lokaal alles groen en installeerden niet.
Veelgestelde vragen
Waarom slagen mijn tests als de module niet installeert?
Omdat een pure Python-suite de Odoo-registry niet opent: ze valideert je logica, niet je databestanden. En een -i op een al geïnstalleerde module verwerkt niets: het testblok wordt niet eens uitgevoerd en de build is groen met nul tests.
Moet ik ook in 17 en 18 een res.groups.privilege aanmaken?
Nee: dat model bestaat niet vóór 19. In 17 en 18 zit de categorie in de groep zelf, met category_id. Het is een van de redenen waarom de beveiligings-XML in de drie series niet hetzelfde bestand kan zijn.
Kan ik numbercall laten staan zodat het in alle drie de versies geldt?
Nee. In 18 en 19 bestaat het veld niet en de aanwezigheid ervan verhindert de installatie van de module. En het weglaten in 17 laat de cron zichzelf uitzetten na de eerste uitvoering. Je moet het bestand per serie genereren.
Als ik op een lege database installeer en het gaat goed, is dat dan niet genoeg?
Nee, want bij een schone installatie worden de scripts van migrations/ niet uitgevoerd en worden de records gemarkeerd met noupdate wel toegepast. Precies de twee gedragingen die bij een update falen, vallen buiten die test.
Installeert je module niet en weet je niet waarom?
We bekijken je beveiligings-XML, je crons, je overgeërfde views en je migratiescripts, en vertellen je wat er in elke serie breekt voordat een klant het ontdekt. Boek een gesprek via Calendly of bel ons op +34 616 809 504.
