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

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
glpi11
plugin
profile
rights
menu
sidebar
gotcha
active high 2026-06-01 2026-06-01
glpi-11
estimate-plugin
webapplications-plugin
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:

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
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_profiles via override getTable()
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

  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:

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
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 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

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)