knowledge-base/records/plugin-dev/KB-PLUGIN-028-glpi11-plugin-rights-and-menu-visibility.md
Gemini 45e1cd8ad8 KB-PLUGIN-028: corrige path do cache de traduções (translations, não locales)
Verificado no GLPI 11.0.8: _cache/<versão>-<hash>-production/translations.
Nota sobre compilar .mo com Python puro quando msgfmt não está disponível.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:26:05 -03:00

573 lines
18 KiB
Markdown

---
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
<?php
namespace GlpiPlugin\Meuplugin;
use CommonGLPI;
use DbUtils;
use ProfileRight;
class Profile extends \Profile
{
public static $rightname = "profile";
public function getTabNameForItem(CommonGLPI $item, $withtemplate = 0)
{
if ($item->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_<key>()
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 <plugin>` 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/<plugin>/` (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
<?php
Session::checkLoginUser();
$class = \GlpiPlugin\Meuplugin\Item::class;
Html::header(
\GlpiPlugin\Meuplugin\Item::getTypeName(2),
$_SERVER['PHP_SELF'],
'management',
$class
);
Search::show($class);
Html::footer();
```
Plugins de referência: `webapplications`, `splititil` (ambos no marketplace do GLPI 11).
### Sintoma quando inclui errado
```
include(): Failed opening '../../../inc/includes.php' for inclusion
(include_path='.:/usr/local/lib/php')
at <plugin>/front/<page>.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 `<plugin_dir>/locales/<lang>.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 (`<lang>_<COUNTRY>.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/<plugin>/locales
msgfmt pt_BR.po -o pt_BR.mo
# 4. Limpar cache do GLPI
# (correção 2026-07-03, verificado no GLPI 11.0.8: o cache de traduções fica
# em _cache/<versão>-<hash>-production/translations — não existe dir "locales")
docker exec <container> sh -c 'rm -rf /var/glpi/files/_cache/*-production/translations'
```
> **Nota (2026-07-03):** sem `msgfmt` no host/container, dá para compilar o
> `.mo` com Python puro (formato binário simples: magic 0x950412de + tabelas
> de offsets; ~60 linhas). Feito no plugin butterfly com sucesso.
#### 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('<plugin>')` no boot — não precisa código adicional. Basta o `.mo` estar em `locales/<lang>.mo` e o usuário ter `glpilanguage` setado.
#### Validar tradução via console
```bash
docker exec <container> 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('<plugin>');
echo __('Total hours', '<plugin>').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_<plugin>()`:
```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) |