Przejdź do głównej treści
Odoo 19 · Instalacja · Tworzenie modułów

Moduł kompiluje się w Odoo 19, a mimo to się nie instaluje

Kompilacja to nie instalacja, a instalacja na czystej bazie to nie aktualizacja bazy, która miała poprzednią wersję. To trzy różne testy, a prawie wszyscy robią tylko pierwszy.

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 wpisujeszCo się dziejeGdzie gryzie
category_id w res.groupsInvalid field 'category_id' in 'res.groups'Nie instaluje się · 19
users lub groups_idZmienione na user_ids i group_idsNie instaluje się · 19
numbercall lub doall w ir.cronInvalid field 'numbercall' in 'ir.cron'Nie instaluje się · 18 i 19
brak numbercall w ir.cronInstaluje się; cron uruchamia się raz i się wyłączaCichy · tylko 17
_sql_constraints w modelu z 19Instaluje się; warning w logu, a ograniczenie nie powstajeCichy · 19
models.Constraint w 18 lub 17module 'odoo.models' has no attribute 'Constraint'Nie instaluje się · 18 i 17
<record id="base.group_system">Instaluje się; przy upgrade nie jest stosowany ponownieCichy · wszystkie trzy
<tree> w dziedziczeniuXpath nie pasuje i widok się sypieNie instaluje się · 18 i 19
Pole przywołane w modyfikatorze i niewyrenderowanemust be present in view but is missingNie 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

  • grep z category_id w dowolnym <record model="res.groups">. Jeśli coś wyjdzie, trzeba utworzyć res.groups.privilege.
  • grep z groups_id i z users w XML: w 19 to group_ids i user_ids.
  • grep z numbercall i doall: usunięte w 18 i 19, obowiązkowe do -1 w 17.
  • grep z _sql_constraints w 19: kończy się warning w logu, a ograniczenie nie powstaje. I z models.Constraint w 18 i 17: ta nazwa tam nie istnieje i moduł w ogóle się nie importuje.
  • grep z <tree w 18/19 i z <list w 17, nie zapominając o view_mode w 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.

Inżynieria dla partnerów OdooWłasny produkt, który instaluje się w 17, 18 i 19, z prawdziwym gate w każdej serii.Migracja do Odoo 19Co sprawdzić, w jakiej kolejności i co podnosi koszt migracji.Błędy migracji do Odoo 19Poprzedni poziom: co się psuje, zanim dojdziesz do builda.Co przynosi Odoo 19Zmiany funkcjonalne, żeby zdecydować, czy warto się przenosić.

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.

Porozmawiaj z inżynierem