--- id: KB-PLUGIN-028 title: GLPI 11 — Plugin com menu próprio precisa registrar rights via Profile domain: plugin-dev tags: - glpi11 - plugin - profile - rights - menu - sidebar - gotcha status: active severity: high created_at: 2026-06-01 updated_at: 2026-06-01 applies_to: - glpi-11 - estimate-plugin - webapplications-plugin related_records: - KB-PLUGIN-013 --- # GLPI 11 — Plugin com menu próprio precisa registrar rights via Profile ## Sintoma Plugin instalado, ativo e habilitado no marketplace, classes carregando corretamente via autoload, **mas o menu não aparece** na sidebar do GLPI (nem o item dentro de Gerência / Helpdesk / Administração). Não há erro no log — o menu é simplesmente omitido silenciosamente. ## Causa raiz GLPI executa `Session::haveRight($rightname, READ)` antes de renderizar qualquer entrada de menu de um itemtype. Se o **right não existir** na tabela `glpi_profilerights` (jamais foi cadastrado pelo plugin), `haveRight()` retorna `0` e o menu é ocultado. Verificação: ```sql SELECT COUNT(*) FROM glpi_profilerights WHERE name LIKE '%plugin_meuplugin%'; -- retorna 0 → você está com o problema ``` ## Solução padrão GLPI 11 Plugins que expõem itemtypes próprios DEVEM ter uma classe `Profile` que: 1. Estende `\Profile` 2. Expõe `getAllRights()` listando os rights 3. Implementa `initProfile()` para registrar os rights em `glpi_profilerights` 4. Implementa `createFirstAccess($profile_id)` para conceder full access ao perfil que instalou ### Esqueleto da classe `src/Profile.php` ```php getType() === 'Profile' && $item->getField('interface') === 'central') { return self::createTabEntry(MeuItemtype::getTypeName(2)); } return ''; } public static function displayTabContentForItem(CommonGLPI $item, $tabnum = 1, $withtemplate = 0) { if ($item->getType() === 'Profile') { self::addDefaultProfileInfos($item->getID(), [ 'plugin_meuplugin_x' => 0, ]); (new self())->showForm($item->getID()); } return true; } public static function getAllRights($all = false): array { return [ [ 'itemtype' => MeuItemtype::class, 'label' => MeuItemtype::getTypeName(2), 'field' => 'plugin_meuplugin_x', ], ]; } public static function initProfile(): void { $dbu = new DbUtils(); foreach ((new self())->getAllRights(true) as $data) { if ($dbu->countElementsInTable('glpi_profilerights', ['name' => $data['field']]) === 0) { ProfileRight::addProfileRights([$data['field']]); } } } public static function createFirstAccess($profiles_id): void { self::addDefaultProfileInfos($profiles_id, [ 'plugin_meuplugin_x' => READ + CREATE + UPDATE + DELETE + PURGE, ], true); } public static function addDefaultProfileInfos($profiles_id, array $rights, bool $drop_existing = false): void { $dbu = new DbUtils(); $profileRight = new ProfileRight(); foreach ($rights as $name => $value) { $exists = $dbu->countElementsInTable('glpi_profilerights', [ 'profiles_id' => $profiles_id, 'name' => $name, ]) > 0; if ($exists && $drop_existing) { $profileRight->deleteByCriteria(['profiles_id' => $profiles_id, 'name' => $name]); $exists = false; } if (!$exists) { $profileRight->add([ 'profiles_id' => $profiles_id, 'name' => $name, 'rights' => $value, ]); } } } } ``` ### Registro em `setup.php` ```php $PLUGIN_HOOKS['change_profile']['meuplugin'] = [ 'GlpiPlugin\\Meuplugin\\Profile', 'initProfile' ]; Plugin::registerClass('GlpiPlugin\\Meuplugin\\Profile', [ 'addtabon' => ['Profile'] ]); ``` ### Chamada no `hook.php` (install) ```php function plugin_meuplugin_install(): bool { // ... criação de tabelas ... \GlpiPlugin\Meuplugin\Profile::initProfile(); if (isset($_SESSION['glpiactiveprofile']['id'])) { \GlpiPlugin\Meuplugin\Profile::createFirstAccess( (int) $_SESSION['glpiactiveprofile']['id'] ); } return true; } ``` ## Mecanismo do menu (menu_toadd) — como o itemtype entra na sidebar Registrar o right faz o menu *poder* aparecer; para ele *de fato* aparecer, o itemtype precisa ser injetado numa seção do menu via o hook `menu_toadd` (lido em `Html::generateMenuSession()` / `Html.php`): ```php // setup.php, dentro de plugin_init_() Plugin::registerClass('GlpiPlugin\\Meuplugin\\MeuItemtype'); $PLUGIN_HOOKS['menu_toadd']['meuplugin'] = [ 'config' => 'GlpiPlugin\\Meuplugin\\MeuItemtype', // ou vários: 'config' => ['Classe1', 'Classe2'] ]; ``` Seções válidas (chave do array): **`assets`, `helpdesk`, `management`, `tools`, `admin`, `config`** (Setup), `plugins`. Itemtype administrativo de configuração → `config`; ver taxonomia em [KB-PLUGIN-029]. O `getMenuContent()` herdado de `CommonGLPI`/`CommonDBTM` já monta a entrada (título, ícone, links search/add) a partir de `getTypeName()`, `getIcon()`, `getSearchURL()` e `canCreate()` — **gated por `canView()`** (que depende do right). Não precisa sobrescrever `getMenuContent()` para um CRUD simples. ## Gotcha — Instalação via CLI `bin/console glpi:plugin:install -f ` roda **sem sessão GLPI**, então `$_SESSION['glpiactiveprofile']['id']` é `null` e `createFirstAccess()` não é executado. Resultado: rights são registrados na tabela mas com valor `0` (sem permissão real). ### Mitigação A — Web-first install Recomendado: ativar o plugin pela primeira vez **via interface web** logado como Super-Admin. A sessão está ativa e o `createFirstAccess()` concede full access automaticamente. ### Mitigação B — Forçar via SQL após CLI install Quando install foi via CLI e você precisa destravar rapidamente: ```sql UPDATE glpi_profilerights SET rights = 31 -- READ+CREATE+UPDATE+DELETE+PURGE WHERE name IN ('plugin_meuplugin_x', 'plugin_meuplugin_y') AND profiles_id IN (SELECT id FROM glpi_profiles WHERE interface='central'); ``` Depois faça logout/login no GLPI pra a sessão recarregar os rights. ### Mitigação C — install.php que concede pra todos os admins Pode-se estender o `install` pra dar full access automaticamente a todos os profiles com `interface='central'`: ```php foreach ($DB->request(['FROM' => 'glpi_profiles', 'WHERE' => ['interface' => 'central']]) as $p) { \GlpiPlugin\Meuplugin\Profile::createFirstAccess((int) $p['id']); } ``` Mas isso pode ser intrusivo — perfis de Observer/Read-Only ganhariam create/delete por padrão. Avaliar caso a caso. ## Conflito de nomes — `Profile` colide com dropdown Se o plugin já tem uma classe `Profile` pra outro conceito (ex: catálogo de perfis de executor, perfis de licenciamento, etc.), há colisão com a classe `Profile` exigida pra rights. ### Solução Renomear a classe que NÃO é a de rights pra algo semanticamente mais claro. Exemplos do plugin Estimate: - `Profile` (executor) → `ExecutorProfile` - Tabela mantém `glpi_plugin_estimate_profiles` via override `getTable()` ```php class ExecutorProfile extends CommonDropdown { public static function getTable($classname = null) { return 'glpi_plugin_estimate_profiles'; } } ``` A classe `Profile` (extends `\Profile`) **sempre** fica reservada pra gestão de rights. ## Checklist de validação Antes de jogar a culpa no menu/cache, confira: ```sql -- 1. Right existe? SELECT name, COUNT(*) c FROM glpi_profilerights WHERE name LIKE '%plugin_meuplugin%' GROUP BY name; -- 2. Profile atual tem o right ativo (>0)? SELECT pr.name, pr.rights FROM glpi_profilerights pr JOIN glpi_profiles p ON p.id = pr.profiles_id WHERE p.id = ? AND pr.name LIKE '%plugin_meuplugin%'; ``` Se ambos retornam valores válidos (>0), o menu **vai aparecer** após logout/login (sessão precisa recarregar o cache de rights). ## Lições 1. **Menu silenciosamente oculto é sintoma clássico de right ausente.** 2. CLI install é parcial — sempre validar com web install ou SQL update. 3. Classe `Profile` (extends `\Profile`) é **convenção rígida** do GLPI — qualquer conceito de "perfil" no domínio do plugin precisa de outro nome. 4. Logout/login é necessário pra a sessão recarregar rights (não basta refresh). ## Bônus — Páginas `front/` no GLPI 11 NÃO usam `include('inc/includes.php')` No GLPI 9/10, todo plugin começava com: ```php include('../../../inc/includes.php'); ``` No **GLPI 11 (Symfony)**, isso quebra porque: 1. O marketplace pode estar em `/var/glpi/marketplace//` (fora do tree do GLPI core que está em `/var/www/glpi/`) 2. `dirname(__DIR__, 3)` ou `../../../` resolve para path errado 3. O `LegacyFileLoadController` já bootou GLPI/autoload antes de invocar o arquivo ### Padrão correto GLPI 11 Começar direto sem include: ```php /front/.php line 7 ``` ### Gotcha — Funções SQL agregadas no query builder do GLPI `$DB->request()` é um query builder que **escapa tudo como nome de coluna** por padrão. Passar `'COUNT(*) AS cnt'` como string em `SELECT` gera SQL inválido: ``` MySQL query error: Unknown column 'COUNT(*)' in 'SELECT' ``` Porque a query final fica: ```sql SELECT `plugin_estimate_states_id`, `COUNT(*)` AS `cnt` FROM ... ^^^^^^^^^^^^ escapado como coluna ``` **Solução:** usar `Glpi\DBAL\QueryExpression` pra funções SQL: ```php use Glpi\DBAL\QueryExpression; $DB->request([ 'SELECT' => [ 'plugin_estimate_states_id', new QueryExpression('COUNT(*) AS ' . $DB->quoteName('cnt')), ], 'FROM' => self::getTable(), 'WHERE' => ['is_deleted' => 0], 'GROUPBY' => 'plugin_estimate_states_id', ]); ``` Vale pra `COUNT(*)`, `SUM()`, `AVG()`, `MAX()`, `MIN()`, `IF()`, `CASE WHEN`, etc. Sempre quotar nomes de coluna referenciados com `$DB->quoteName(...)`. ### Gotcha — `name` duplicado em templates Twig customizados Quando você cria um template Twig pro form do itemtype e estende `generic_show_form.html.twig`, o GLPI **já renderiza automaticamente** os campos padrão (`name`, `entities_id`, datas). Adicionar `fields.textField('name', ...)` no bloco `more_fields` causa **duplicação**. ```twig {# ERRADO — duplica o campo Nome #} {% block more_fields %} {{ fields.textField('name', item.fields['name'], __('Name')) }} ... {% endblock %} {# CERTO — só campos adicionais #} {% block more_fields %} {# 'name' renderizado pelo generic_show_form #} {{ fields.dropdownField('Client', 'plugin_client_id', ...) }} ... {% endblock %} ``` Outros campos auto-renderizados (não duplicar): `id`, `name`, `entities_id`, `is_recursive`, `date_creation`, `date_mod`. ### Internacionalização de plugin (gettext .po/.mo) Plugins GLPI usam **gettext** com domínio por plugin. Cada `__('String', 'estimate')` busca em `/locales/.mo`. #### Estrutura ``` estimate/ └── locales/ ├── pt_BR.po ← fonte editável (UTF-8) └── pt_BR.mo ← binário compilado (consumido pelo GLPI) ``` Sem prefixo de domínio no filename — convenção é só o código de idioma (`_.po/.mo`). #### Workflow ```bash # 1. Instalar gettext (pacote completo, não só -base) apt-get install -y gettext # provê msgfmt # 2. Editar locales/pt_BR.po (formato gettext padrão) # 3. Compilar cd marketplace//locales msgfmt pt_BR.po -o pt_BR.mo # 4. Limpar cache do GLPI docker exec find /var/glpi/files/_cache -name "locales" -type d -exec rm -rf {} + ``` #### Formato `.po` ``` msgid "" msgstr "" "Language: pt_BR\n" "Content-Type: text/plain; charset=UTF-8\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" # Termo singular msgid "Estimate" msgstr "Estimativa" # Termo plural (suporta nplurals) msgid "Item" msgid_plural "Items" msgstr[0] "Item" msgstr[1] "Itens" # Termo com placeholder msgid "Add %s" msgstr "Adicionar %s" ``` #### Carregamento automático O GLPI 11 chama `Plugin::loadLang('')` no boot — não precisa código adicional. Basta o `.mo` estar em `locales/.mo` e o usuário ter `glpilanguage` setado. #### Validar tradução via console ```bash docker exec php -r " chdir('/var/www/glpi'); require 'vendor/autoload.php'; \$k = new Glpi\Kernel\Kernel('production', false); \$k->boot(); \$_SESSION['glpilanguage'] = 'pt_BR'; \Session::loadLanguage(); \Plugin::loadLang(''); echo __('Total hours', '').PHP_EOL; " ``` #### Termos que GLPI core já traduz (não precisa repetir no plugin) Estes são traduzidos pelo `.mo` do GLPI core. Use `__('Name')` (sem 2º arg) e herda: `Name`, `Description`, `Status`, `Category`, `Color`, `Date`, `Hours`, `Quantity`, `Currency`, `Document`, `Documents`, `Notes`, `Historical`, `Profile`, `User`, `Group`, `Entity`, `Add`, `Save`, `Delete`, `Cancel`, e a maioria dos verbos/labels comuns. ### Constantes úteis disponíveis no front (já definidas) | Constante | Valor típico no container | Uso | |---|---|---| | `GLPI_ROOT` | `/var/www/glpi` | Path do GLPI core | | `GLPI_MARKETPLACE_DIR` | `/var/glpi/marketplace` | Onde plugins de marketplace ficam (pode diferir de `GLPI_ROOT`) | | `GLPI_CONFIG_DIR` | `/var/glpi/config` | Config + chaves OAuth | | `GLPI_PLUGIN_DOC_DIR` | `/var/glpi/files/_plugins` | Storage de arquivos por plugin | ## Bônus 2 — Sidebar de tabs no form do itemtype GLPI exibe um menu lateral de tabs em cada itemtype (Documento, Itens associados, Notas, Histórico, etc.). Para um plugin replicar isso: ### Pai (Estimate.php — itemtype principal) — implementa `defineTabs()` ```php public function defineTabs($options = []) { $ong = []; $this->addDefaultFormTab($ong); // form principal (campos do item) $this->addStandardTab(EstimateItem::class, $ong, $options); // tab "Itens" (filho custom) $this->addStandardTab('Document_Item', $ong, $options); // anexos nativos $this->addStandardTab('Notepad', $ong, $options); // notas nativas $this->addStandardTab('Log', $ong, $options); // histórico nativo return $ong; } ``` ### Filho (EstimateItem.php — entidade que aparece como tab no pai) ```php public function getTabNameForItem(CommonGLPI $item, $withtemplate = 0) { if ($item instanceof Estimate) { $count = (new DbUtils())->countElementsInTable(self::getTable(), [ self::$items_id => $item->getID(), ]); return self::createTabEntry(self::getTypeName(2), $count); } return ''; } public static function displayTabContentForItem(CommonGLPI $item, $tabnum = 1, $withtemplate = 0) { if ($item instanceof Estimate) { self::showForEstimate($item); // sua função de renderização } return true; } ``` ### Registro em `setup.php` ```php Plugin::registerClass('GlpiPlugin\\Estimate\\EstimateItem', [ 'addtabon' => ['GlpiPlugin\\Estimate\\Estimate'] ]); ``` `addtabon` informa ao GLPI que esse itemtype deve aparecer como tab nos itemtypes listados. ### Tabs nativos que "saem de graça" | Tab nativo | Class GLPI | O que faz | |---|---|---| | Documentos | `Document_Item` | Anexar arquivos ao itemtype | | Notas | `Notepad` | Notas privadas do usuário | | Histórico | `Log` | Audit log automático de mudanças | | Reservas | `Reservation` | Reservar item por período (CIs) | | Itens associados | `KnowbaseItem_Item` ou `Item_Devices` | Itens relacionados | ### Gotcha — `count_on_tabs` Mostrar contador na label da tab depende de `$_SESSION['glpishow_count_on_tabs']` (config GLPI). Sempre testar com `?? true`: ```php if ($_SESSION['glpishow_count_on_tabs'] ?? true) { $count = ...; } ``` ### Gotcha — Tabs nativos exigem rights `Document_Item`, `Notepad`, `Log` etc. fazem check de `canView()` no usuário corrente. No CLI (`bin/console`) a sessão não existe → `addStandardTab` retorna sem adicionar. Validar tabs sempre no **browser logado**, não no console. ### Linkar itemtype a Ticket / Project (rastreabilidade bidirecional) Para que seu plugin apareça nos dropdowns "tipo de item" ao **adicionar associação** num Ticket ou Project, precisa adicionar ao `$CFG_GLPI` em `plugin_init_()`: ```php global $CFG_GLPI; $CFG_GLPI['ticket_types'][] = 'GlpiPlugin\\Meuplugin\\MeuItemtype'; $CFG_GLPI['project_asset_types'][] = 'GlpiPlugin\\Meuplugin\\MeuItemtype'; ``` E pra o tab **inverso** (Meu Itemtype aparecer como tab em Ticket/Project), usar `addtabon`: ```php Plugin::registerClass('GlpiPlugin\\Meuplugin\\MeuItemtype', [ 'addtabon' => ['Ticket', 'Project'] ]); ``` E no `defineTabs()` do seu itemtype, adicionar os tabs nativos: ```php $this->addStandardTab('Item_Ticket', $ong, $options); $this->addStandardTab('Item_Project', $ong, $options); ``` Arrays úteis em `$CFG_GLPI`: | Array | Para que serve | |---|---| | `ticket_types` | Item pode ser associado a Ticket via `Item_Ticket` | | `project_asset_types` | Item pode ser associado a Project via `Item_Project` | | `asset_types` | Item aparece como "ativo" em listagens genéricas | | `link_types` | Item pode ser link de relacionamento via `Link_Itemtype` | | `document_types` | Item pode receber Documents anexados via `Document_Item` | | `state_types` | Item pode ter `states_id` (estado do ativo) |