Jeśli tu trafiasz, to dlatego, że build wyrzucił Ci coś w rodzaju ValueError: Invalid field 'category_id' in 'res.groups', a lokalnie wszystko działało. To nie Twoje środowisko. Linter, import i testy jednostkowe nie wykonują tej części Odoo, która zawodzi: rejestru budowanego podczas instalacji.
Temat „co zmienia się w Odoo 19” jest już dobrze przetarty. Tu chodzi o kolejny stopień: moduł przechodzi linter, importuje się bez błędu, a mimo to -i kończy się błędem. Albo gorzej: instaluje się i po cichu działa źle.
Co sprawdza Twoje CI, a czego nie
python -m compileall sprawdza składnię Pythona. xmllint --noout sprawdza, czy XML jest poprawnie sformułowany. Zestaw testów „czystych”, bez uruchomionego Odoo, sprawdza Twoją logikę. Żaden z tych trzech nie otwiera rejestru Odoo, a właśnie tam leży błąd.
Instalacja to coś innego: Odoo buduje rejestr i każdy <record> kończy się create() lub write() w ORM. A write() sprawdza nazwy pól jedna po drugiej: w odoo/orm/models.py wersji 19.0 KeyError na self._fields[field_name] kończy się jako ValueError: Invalid field 'category_id' in 'res.groups'. To nie błąd składni, to błąd wykonania XML.
I największa pułapka, ta, która zmienia gate w teatr: -i na module już zainstalowanym nic nie robi. W odoo/modules/loading.py wersji 19.0 update_operation wynosi 'install' tylko wtedy, gdy stan to to install; jeśli to installed, wynosi None. A przy None, gdy w linii poleceń jest -i lub -u, ani nie ładują się dane, ani nie wchodzi się do bloku testów. Zielony build, zero testów. Zanim uwierzysz w jakikolwiek gate, sprawdź, ile testów twierdzi, że uruchomił.
res.groups.privilege to nie zmienione pole: to nowy model
Przewodniki migracji zawierają tabelę zmian nazw i to naprawia połowę przypadków. category_id w niej nie ma, bo Odoo 19 go nie zmieniło: usunęło go z res.groups. Wyszukiwanie i zamiana nie ma w co celować.
W 17 i 18 res.groups.category_id wskazuje na ir.module.category. W 19 jest nowy model pośredni, res.groups.privilege (odoo/addons/base/models/res_groups_privilege.py, czternaście linii): grupa ma privilege_id, a to uprawnienie ma category_id. Przy okazji 19 dodaje UNIQUE (privilege_id, name): dwie grupy o nazwie „Manager” pod tym samym uprawnieniem nie zainstalują się.
Poprawka nie polega na zmianie nazwy: to utworzenie rekordu modelu, który w Twojej wersji źródłowej nie istniał. Tak wygląda to w naszych modułach, z komentarzem, który zostawiamy w pliku, żeby się to nie powtórzyło:
<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>
Wokół są też prawdziwe zmiany nazw i te naprawia zwykła zamiana: res.groups.users zmienia się w user_ids, res.users.groups_id w group_ids, i tak samo w ir.actions.act_window oraz ir.actions.server. Czego nie zmieniono, to tabele: res_groups_users_rel(gid, uid) i res_groups_implied_rel(gid, hid) są identyczne w 17, 18 i 19. SQL migracji przetrwa wszystkie trzy serie; otaczający go kod ORM nie.
base.group_system z noupdate: instaluje się dobrze, a uprawnienia zostają bez zmian
Typowy sposób, by administrator odziedziczył Twoją grupę, to <record> na cudzym xmlid:
<record id="base.group_system" model="res.groups">
<field name="implied_ids" eval="[(4, ref('group_mi_manager'))]"/>
</record>
Dzieje się tak przy nowej instalacji. Przy aktualizacji Odoo ignoruje to po cichu. Reguła jest w _load_records (odoo/orm/models.py) i mieści się w jednej linii: if not (update and d_noupdate): to_update.append(data). O wszystkim decyduje nie Twój plik, lecz kolumna noupdate istniejącego już wiersza w ir_model_data dla tego xmlid. Nie zakładaj niczego w żadną stronę: zapytaj swoją bazę danych.
SELECT module, name, noupdate FROM ir_model_data
WHERE module = 'base' AND name IN ('group_system', 'group_user');
W naszej produkcji na Odoo.sh, sprawdzonej 3 września 2026, oba wiersze wracają z noupdate = t. Przy tej wartości powyższy XML nigdy nie jest stosowany ponownie przy upgrade.
Dlaczego to ważne: jeśli dodatkowo ustawisz groups="mi_modulo.group_manager" na polu z danymi uwierzytelniającymi, w już zainstalowanej bazie ta grupa zostanie bez żadnego członka i nikt (także administrator) nie odczyta ani nie zapisze tego pola. Kod wewnętrzny z sudo() nadal działa, więc żaden test tego nie widzi: testy działają na czystej instalacji, gdzie <record> został zastosowany. Tak właśnie nam się przytrafiło.
Przepis: zostawić XML, który obsługuje nowe instalacje, i dodać migrację migrations/<serie>.<version>/post-migrate.py, która zapisze to w idempotentnym SQL na res_groups_implied_rel i res_groups_users_rel: implied_ids propaguje tylko do użytkowników przez ORM, więc trzeba wstawić ich także. Do tego test, który zawiedzie, jeśli grupa zostanie pusta. Domknięcie przechodnie to all_implied_ids w 19 i trans_implied_ids w 17 i 18.
ir.cron bez numbercall: cron uruchamia się raz i sam się wyłącza
Tego nie widać do następnego tygodnia. W Odoo 17 odoo/addons/base/models/ir_cron.py deklaruje numbercall = fields.Integer(..., default=1, ...). <record model="ir.cron">, który tego nie ustawi, dziedziczy tę 1. Po zakończeniu pierwszego wykonania call_count_left daje 0, a UPDATE zamykające wykonanie zapisuje active = job['active'] and bool(call_count_left): fałsz. Dla pewności, zapytanie wybierające zadania filtruje po AND numbercall != 0.
Rezultat: cron wykonuje się raz, sam się dezaktywuje i nie zostawia śladu. Ani wyjątku, ani linii w logu, ani niczego w interfejsie. Wygląda dokładnie jak „cron nie startuje”. Poprawka to <field name="numbercall">-1</field>, wartość ujemna oznaczająca „bez limitu”.
I tu nieprzyjemna symetria: Odoo 18 usunęło to pole, a 19 nigdy go nie miało. Wpisanie numbercall w 19 uniemożliwia instalację (Invalid field 'numbercall' in 'ir.cron'); niewpisanie go w 17 po cichu psuje działanie. Ten sam plik zawodzi w dwóch przeciwnych kierunkach. Z doall tak samo.
Ten błąd popełniamy też u siebie. Audytując nasz katalog, znaleźliśmy czternaście rekordów ir.cron w czterech konektorach przeniesionych do 17 bez ani jednego numbercall; po rozszerzeniu przeglądu na gałąź 17.0 wdrożenia, które faktycznie działa, 130 rekordów w piętnastu modułach, żaden z tym polem. Mówiąc wprost: znaleźliśmy to, bo poszliśmy tego szukać z grep, a nie dlatego, że coś się wysypało.
Zestawienie i w której wersji każda pułapka gryzie
To, co zebraliśmy, instalując naprawdę na Odoo.sh we wszystkich trzech seriach, na naszym własnym katalogu. Żadnej z tych pułapek nie wyłapie kompilator ani linter. Wiersze oznaczone „cichy” są gorsze: moduł się instaluje, a build jest zielony.
| Co wpisujesz | Co się dzieje | Gdzie gryzie |
|---|---|---|
category_id w res.groups | Invalid field 'category_id' in 'res.groups' | Nie instaluje się · 19 |
users lub groups_id | Zmienione na user_ids i group_ids | Nie instaluje się · 19 |
numbercall lub doall w ir.cron | Invalid field 'numbercall' in 'ir.cron' | Nie instaluje się · 18 i 19 |
brak numbercall w ir.cron | Instaluje się; cron uruchamia się raz i się wyłącza | Cichy · tylko 17 |
_sql_constraints w modelu z 19 | Instaluje się; warning w logu, a ograniczenie nie powstaje | Cichy · 19 |
models.Constraint w 18 lub 17 | module 'odoo.models' has no attribute 'Constraint' | Nie instaluje się · 18 i 17 |
<record id="base.group_system"> | Instaluje się; przy upgrade nie jest stosowany ponownie | Cichy · wszystkie trzy |
<tree> w dziedziczeniu | Xpath nie pasuje i widok się sypie | Nie instaluje się · 18 i 19 |
| Pole przywołane w modyfikatorze i niewyrenderowane | must be present in view but is missing | Nie instaluje się · tylko 17 |
Zwróć uwagę na ostatni wiersz: tę kontrolę wykonuje tylko 17. Komunikat nadal jest w kodzie 18 i 19, ale tam obejmuje wyłącznie nazwy i akcje, nie pole przywołane w modyfikatorze. Jeśli rozwijasz pod 19 i portujesz wstecz, widok, który u Ciebie instaluje się bez problemu, w 17 się wysypie. Nie wszystkie pułapki idą w tym samym kierunku.
Jak to naprawdę wykryć: zainstalować dwa razy, na dwóch różnych bazach
Test A: czysta baza, -i. Wyłapuje wszystko, co uniemożliwia instalację: pola, które już nie istnieją, xpath, który nie pasuje, nieprawidłowe widoki, ograniczenia odrzucane przez bazę. Nie wyłapuje niczego z tego, co zdarza się tylko przy aktualizacji: tu update jest fałszem, noupdate są ignorowane, a skrypty migrations/ w ogóle się nie wykonują, bo migrate_module (odoo/modules/migration.py) wychodzi drzwiami, jeśli moduł nie jest w to upgrade.
Test B: baza z poprzednią wersją, -u. Wyłapuje to, co pojawia się tylko przy aktualizacji: rekordy, które nie są stosowane ponownie, nowe kolumny bez wypełnienia, migracje, które padają w połowie, stare dane, które już nie pasują. Tu Twoje pre-migrate i post-migrate faktycznie działają i to jedyny sposób, by wiedzieć, czy działają.
Żaden nie zastępuje drugiego, a jeśli publikujesz dla 17, 18 i 19, to sześć instalacji, nie dwie. Praktyczny szczegół: przed -i zresetuj przez prawdziwą dezinstalację w ORM. Ręczna zmiana stanu przez psql zostawia graf niespójny i loader pomija moduł po cichu: znowu zielony build, który niczego nie sprawdził.
Lista kontrolna przed wysłaniem
grepzcategory_idw dowolnym<record model="res.groups">. Jeśli coś wyjdzie, trzeba utworzyćres.groups.privilege.grepzgroups_idi zusersw XML: w 19 togroup_idsiuser_ids.grepznumbercallidoall: usunięte w 18 i 19, obowiązkowe do-1w 17.grepz_sql_constraintsw 19: kończy się warning w logu, a ograniczenie nie powstaje. I zmodels.Constraintw 18 i 17: ta nazwa tam nie istnieje i moduł w ogóle się nie importuje.grepz<treew 18/19 i z<listw 17, nie zapominając oview_modew akcjach.- Dla każdego
<record id="base....">: czy jest migracja na upgrade i test, który zawiedzie, jeśli grupa zostanie pusta? - Zainstalować na czystej bazie. Policzyć wykonane testy. Jeśli jest zero, gate jest pusty.
- Zainstalować na bazie z poprzednią wersją. Znów policzyć testy.
- Powtórzyć w każdej serii, którą zamierzasz publikować.
Pierwszych pięć punktów to statyczny skrypt, który działa w sekundę; prawdziwy build trwa minuty. Zrób najpierw tani, ale nie pozwól, by zastąpił drogi: grep znajduje to, czego już umiesz szukać, a build znajduje to, czego nie umiesz.
Jeśli zaczynasz od migracji, poprzedni poziom jest w typowych błędach przy migracji do Odoo 19.
Zasada podstawowa, jedyna do zapamiętania: kompilacja, parsowanie i zielone testy to nie to samo co instalacja. Dwa moduły, które kosztowały nas w tym roku najwięcej, miały lokalnie wszystko na zielono i się nie instalowały.
Najczęściej zadawane pytania
Dlaczego moje testy przechodzą, skoro moduł się nie instaluje?
Bo zestaw testów w czystym Pythonie nie otwiera rejestru Odoo: waliduje Twoją logikę, nie Twoje pliki danych. A -i na module już zainstalowanym niczego nie przetwarza: blok testów w ogóle się nie wykonuje, a build jest zielony przy zerze testów.
Czy muszę tworzyć res.groups.privilege także w 17 i 18?
Nie: ten model nie istnieje przed 19. W 17 i 18 kategoria jest w samej grupie, z category_id. To jeden z powodów, dla których XML bezpieczeństwa nie może być tym samym plikiem we wszystkich trzech seriach.
Czy mogę zostawić numbercall, żeby działało we wszystkich trzech wersjach?
Nie. W 18 i 19 to pole nie istnieje i jego obecność uniemożliwia instalację modułu. A usunięcie go w 17 sprawia, że cron wyłącza się sam po pierwszym wykonaniu. Plik trzeba generować dla każdej serii.
Jeśli zainstaluję na czystej bazie i pójdzie dobrze, czy to nie wystarczy?
Nie, bo przy czystej instalacji skrypty migrations/ nie wykonują się, a rekordy oznaczone noupdate są stosowane. Dokładnie te dwa zachowania, które zawodzą przy aktualizacji, zostają poza tym testem.
Twój moduł się nie instaluje i nie wiesz dlaczego?
Przeglądamy Twój XML bezpieczeństwa, crony, widoki dziedziczone i skrypty migracji i mówimy, co psuje się w każdej serii, zanim odkryje to klient. Zarezerwuj rozmowę w Calendly lub zadzwoń: +34 616 809 504.
