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>
18 KiB
| id | title | domain | tags | status | severity | created_at | updated_at | applies_to | related_records | |||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| KB-PLUGIN-028 | GLPI 11 — Plugin com menu próprio precisa registrar rights via Profile | plugin-dev |
|
active | high | 2026-06-01 | 2026-06-01 |
|
|
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:
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:
- Estende
\Profile - Expõe
getAllRights()listando os rights - Implementa
initProfile()para registrar os rights emglpi_profilerights - Implementa
createFirstAccess($profile_id)para conceder full access ao perfil que instalou
Esqueleto da classe src/Profile.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
$PLUGIN_HOOKS['change_profile']['meuplugin'] = [
'GlpiPlugin\\Meuplugin\\Profile', 'initProfile'
];
Plugin::registerClass('GlpiPlugin\\Meuplugin\\Profile', [
'addtabon' => ['Profile']
]);
Chamada no hook.php (install)
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):
// 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:
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':
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_profilesvia overridegetTable()
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:
-- 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
- Menu silenciosamente oculto é sintoma clássico de right ausente.
- CLI install é parcial — sempre validar com web install ou SQL update.
- Classe
Profile(extends\Profile) é convenção rígida do GLPI — qualquer conceito de "perfil" no domínio do plugin precisa de outro nome. - 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:
include('../../../inc/includes.php');
No GLPI 11 (Symfony), isso quebra porque:
- O marketplace pode estar em
/var/glpi/marketplace/<plugin>/(fora do tree do GLPI core que está em/var/www/glpi/) dirname(__DIR__, 3)ou../../../resolve para path errado- O
LegacyFileLoadControllerjá bootou GLPI/autoload antes de invocar o arquivo
Padrão correto GLPI 11
Começar direto sem include:
<?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:
SELECT `plugin_estimate_states_id`, `COUNT(*)` AS `cnt` FROM ...
^^^^^^^^^^^^ escapado como coluna
Solução: usar Glpi\DBAL\QueryExpression pra funções SQL:
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.
{# 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
# 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
msgfmtno host/container, dá para compilar o.mocom 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
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()
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)
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
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:
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>():
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:
Plugin::registerClass('GlpiPlugin\\Meuplugin\\MeuItemtype', [
'addtabon' => ['Ticket', 'Project']
]);
E no defineTabs() do seu itemtype, adicionar os tabs nativos:
$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) |