feat: vínculo Entidade -> Projeto Redmine (clientes de suporte recorrente)

- nova aba Redmine em Administração > Entidades (PluginRedmineEntity)
- tabela glpi_plugin_redmine_entities (1:1 entidade -> projeto Redmine, + categoria)
- hook resolve o projeto Redmine por: projeto vinculado OU, na ausência,
  a entidade do chamado (match exato, sem herança de árvore)
- twigs do tab generalizados (owner_field/owner_id) p/ reuso projeto+entidade

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gemini 2026-06-29 22:00:36 -03:00
parent c6ac9f7f04
commit 92d1ac044a
7 changed files with 236 additions and 26 deletions

79
front/entity.form.php Normal file
View file

@ -0,0 +1,79 @@
<?php
include ("../../../inc/includes.php");
Session::checkLoginUser();
if (isset($_POST["action"])) {
$action = $_POST["action"];
$entities_id = (int) $_POST["entities_id"];
// Permission check
$entity = new Entity();
if (!$entity->getFromDB($entities_id) || !$entity->can($entities_id, UPDATE)) {
Html::displayRightError();
}
global $DB;
if ($action === 'sync') {
$metadata = PluginRedmineRedmineapi::syncMetadata();
if ($metadata !== false) {
$DB->update('glpi_plugin_redmine_configs', [
'metadata_cache' => json_encode($metadata)
], ['id' => 1]);
PluginRedmineRedmineapi::syncUsers();
Session::addMessageAfterRedirect(__('Metadados sincronizados com sucesso!', 'redmine'));
} else {
Session::addMessageAfterRedirect(__('Erro ao sincronizar com o Redmine.', 'redmine'), false, ERROR);
}
}
elseif ($action === 'set_category') {
$DB->update('glpi_plugin_redmine_entities', [
'default_category_id' => (int) ($_POST['default_category_id'] ?? 0)
], [
'entities_id' => $entities_id
]);
Session::addMessageAfterRedirect(__('Categoria padrão salva com sucesso!', 'redmine'));
}
elseif ($action === 'process') {
$redmine_project_id = $_POST['redmine_project_id'] ?? '';
if (!empty($redmine_project_id)) {
// LINK OPERATION
$DB->insert('glpi_plugin_redmine_entities', [
'entities_id' => $entities_id,
'redmine_project_id' => $redmine_project_id
]);
Session::addMessageAfterRedirect(__('Entidade vinculada com sucesso!', 'redmine'));
} else {
// CREATE OPERATION
$payload = [
'name' => $_POST['name'],
'identifier' => $_POST['identifier'],
'description' => $_POST['description'],
'is_public' => !empty($_POST['is_public'])
];
if (!empty($_POST['parent_id'])) {
$payload['parent_id'] = $_POST['parent_id'];
}
if (!empty($_POST['enabled_module_names'])) {
$payload['enabled_module_names'] = $_POST['enabled_module_names'];
}
$new_redmine_id = PluginRedmineRedmineapi::createProjectFull($payload);
if ($new_redmine_id) {
$DB->insert('glpi_plugin_redmine_entities', [
'entities_id' => $entities_id,
'redmine_project_id' => $new_redmine_id
]);
Session::addMessageAfterRedirect(__('Projeto criado e vinculado à entidade com sucesso!', 'redmine'));
} else {
Session::addMessageAfterRedirect(__('Erro ao criar projeto no Redmine.', 'redmine'), false, ERROR);
}
}
}
Html::redirect($CFG_GLPI["root_doc"] . "/front/entity.form.php?id=" . $entities_id);
}
Html::displayErrorAndDie("Lost");

View file

@ -91,6 +91,20 @@ function plugin_redmine_install() {
$migration->addPostQuery($query); $migration->addPostQuery($query);
} }
// Mapping table for GLPI Entities <-> Redmine Projects (1:1, exact match).
// For recurring-support clients whose tickets are born in an entity, with no project.
if (!$DB->tableExists("glpi_plugin_redmine_entities")) {
$query = "CREATE TABLE `glpi_plugin_redmine_entities` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`entities_id` int(11) NOT NULL,
`redmine_project_id` int(11) NOT NULL,
`default_category_id` int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `entities_id` (`entities_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
$migration->addPostQuery($query);
}
// Cache of Redmine users (populated by "Sincronizar Metadados"). // Cache of Redmine users (populated by "Sincronizar Metadados").
if (!$DB->tableExists("glpi_plugin_redmine_users")) { if (!$DB->tableExists("glpi_plugin_redmine_users")) {
$query = "CREATE TABLE `glpi_plugin_redmine_users` ( $query = "CREATE TABLE `glpi_plugin_redmine_users` (
@ -158,7 +172,8 @@ function plugin_redmine_uninstall() {
"glpi_plugin_redmine_tickets", "glpi_plugin_redmine_tickets",
"glpi_plugin_redmine_tasks", "glpi_plugin_redmine_tasks",
"glpi_plugin_redmine_users", "glpi_plugin_redmine_users",
"glpi_plugin_redmine_usermap" "glpi_plugin_redmine_usermap",
"glpi_plugin_redmine_entities"
]; ];
foreach ($tables as $table) { foreach ($tables as $table) {
@ -307,28 +322,40 @@ function _plugin_redmine_process_tickettask(TicketTask $task) {
} }
} }
if (!$projectId) { // Resolve the Redmine project. Priority: the linked GLPI project; if none,
return; // Not linked to a project // fall back to the ticket's ENTITY (1:1, exact match, no tree inheritance).
// $linkRow holds the mapping row (carries default_category_id) in either case.
$redmineProjectId = null;
$linkRow = null;
if ($projectId) {
$mapping = $DB->request([
'FROM' => 'glpi_plugin_redmine_projects',
'WHERE' => ['projects_id' => $projectId]
]);
if (count($mapping) > 0) {
$linkRow = $mapping->current();
$redmineProjectId = (int) $linkRow['redmine_project_id'];
} else {
// Fallback: resolve by the conventional identifier.
$redmineProjectId = PluginRedmineRedmineapi::getProjectByIdentifier("glpi-proj-" . $projectId);
}
} }
// Source of truth for the Redmine project is the link saved by the // Entity-based clients (recurring support): ticket born in a linked entity, no project.
// Project tab (it may use a custom identifier or an existing project). if (!$redmineProjectId) {
$redmineProjectId = null; $entMap = $DB->request([
$projRow = null; 'FROM' => 'glpi_plugin_redmine_entities',
$mapping = $DB->request([ 'WHERE' => ['entities_id' => (int) $ticket->fields['entities_id']]
'FROM' => 'glpi_plugin_redmine_projects', ]);
'WHERE' => ['projects_id' => $projectId] if (count($entMap) > 0) {
]); $linkRow = $entMap->current();
if (count($mapping) > 0) { $redmineProjectId = (int) $linkRow['redmine_project_id'];
$projRow = $mapping->current(); }
$redmineProjectId = (int) $projRow['redmine_project_id'];
} else {
// Fallback: resolve by the conventional identifier.
$redmineProjectId = PluginRedmineRedmineapi::getProjectByIdentifier("glpi-proj-" . $projectId);
} }
if (!$redmineProjectId) { if (!$redmineProjectId) {
Toolbox::logInFile('redmine', "Redmine project not found for GLPI project ID $projectId\n"); Toolbox::logInFile('redmine', "No Redmine link for ticket #$ticketId (no project nor entity link).\n");
return; return;
} }
@ -347,9 +374,9 @@ function _plugin_redmine_process_tickettask(TicketTask $task) {
if (!empty($cfg['default_tracker_id'])) { $extra['tracker_id'] = (int) $cfg['default_tracker_id']; } if (!empty($cfg['default_tracker_id'])) { $extra['tracker_id'] = (int) $cfg['default_tracker_id']; }
if (!empty($cfg['default_priority_id'])) { $extra['priority_id'] = (int) $cfg['default_priority_id']; } if (!empty($cfg['default_priority_id'])) { $extra['priority_id'] = (int) $cfg['default_priority_id']; }
// Category is per project link. // Category is per link (project or entity).
if ($projRow && !empty($projRow['default_category_id'])) { if ($linkRow && !empty($linkRow['default_category_id'])) {
$extra['category_id'] = (int) $projRow['default_category_id']; $extra['category_id'] = (int) $linkRow['default_category_id'];
} }
// Issue status mirrors the GLPI ticket status (mapped). // Issue status mirrors the GLPI ticket status (mapped).

99
inc/entity.class.php Normal file
View file

@ -0,0 +1,99 @@
<?php
if (!defined('GLPI_ROOT')) {
die("Sorry. You can't access this file directly");
}
class PluginRedmineEntity extends CommonDBTM {
static function getTypeName($nb = 0) {
return 'Redmine';
}
function getTabNameForItem(CommonGLPI $item, $withtemplate = 0) {
if ($item->getType() == 'Entity') {
return self::createTabEntry(__('Redmine', 'redmine'), 0, $item::getType(), 'fas fa-sitemap');
}
return '';
}
static function displayTabContentForItem(CommonGLPI $item, $tabnum = 1, $withtemplate = 0) {
if ($item->getType() == 'Entity') {
self::showForEntity($item);
}
return true;
}
static function getIcon() {
return 'fas fa-sitemap';
}
static function showForEntity(Entity $entity) {
global $DB, $CFG_GLPI;
$entities_id = $entity->getID();
$form_url = $CFG_GLPI['root_doc'] . '/plugins/redmine/front/entity.form.php';
// Already linked?
$iterator = $DB->request([
'FROM' => 'glpi_plugin_redmine_entities',
'WHERE' => ['entities_id' => $entities_id]
]);
if (count($iterator) > 0) {
$link = $iterator->current();
$redmineProjectId = (int) $link['redmine_project_id'];
$cat_options = [];
foreach (PluginRedmineRedmineapi::getProjectCategories($redmineProjectId) as $c) {
if (isset($c['id'], $c['name'])) {
$cat_options[$c['id']] = $c['name'];
}
}
\Glpi\Application\View\TemplateRenderer::getInstance()->display('@redmine/project_linked.html.twig', [
'action_url' => $form_url,
'owner_field' => 'entities_id',
'owner_id' => $entities_id,
'redmine_project_id' => $redmineProjectId,
'cat_options' => $cat_options,
'default_category_id' => (int) ($link['default_category_id'] ?? 0),
'csrf_token_value' => Session::getNewCSRFToken(),
]);
return;
}
// Not linked: show the link/create form (reuses the project tab template).
$config_it = $DB->request(['FROM' => 'glpi_plugin_redmine_configs', 'WHERE' => ['id' => 1]]);
$config = count($config_it) > 0 ? $config_it->current() : [];
$metadata = !empty($config['metadata_cache']) ? json_decode($config['metadata_cache'], true) : [];
$redmine_projects = $metadata['projects'] ?? [];
$modules = [
'issue_tracking' => 'Gerenciamento de Tarefas',
'time_tracking' => 'Gerenciamento de tempo',
'news' => 'Notícias',
'documents' => 'Documentos',
'files' => 'Arquivos',
'wiki' => 'Wiki',
'repository' => 'Repositório',
'boards' => 'Fóruns',
'calendar' => 'Calendário',
'gantt' => 'Gantt'
];
$description = \Glpi\RichText\RichText::getTextFromHtml($entity->fields['comment'] ?? '', false, true, true);
\Glpi\Application\View\TemplateRenderer::getInstance()->display('@redmine/project_form.html.twig', [
'action_url' => $form_url,
'owner_field' => 'entities_id',
'owner_id' => $entities_id,
'redmine_projects' => $redmine_projects,
'project_name' => $entity->fields['name'],
'project_description' => $description,
'project_identifier' => 'glpi-ent-' . $entities_id,
'modules' => $modules,
'csrf_token_value' => Session::getNewCSRFToken()
]);
}
}

View file

@ -53,7 +53,8 @@ class PluginRedmineProject extends CommonDBTM {
\Glpi\Application\View\TemplateRenderer::getInstance()->display('@redmine/project_linked.html.twig', [ \Glpi\Application\View\TemplateRenderer::getInstance()->display('@redmine/project_linked.html.twig', [
'action_url' => $CFG_GLPI['root_doc'] . '/plugins/redmine/front/project.form.php', 'action_url' => $CFG_GLPI['root_doc'] . '/plugins/redmine/front/project.form.php',
'projects_id' => $projects_id, 'owner_field' => 'projects_id',
'owner_id' => $projects_id,
'redmine_project_id' => $redmineProjectId, 'redmine_project_id' => $redmineProjectId,
'cat_options' => $cat_options, 'cat_options' => $cat_options,
'default_category_id' => (int) ($link['default_category_id'] ?? 0), 'default_category_id' => (int) ($link['default_category_id'] ?? 0),
@ -92,7 +93,8 @@ class PluginRedmineProject extends CommonDBTM {
\Glpi\Application\View\TemplateRenderer::getInstance()->display('@redmine/project_form.html.twig', [ \Glpi\Application\View\TemplateRenderer::getInstance()->display('@redmine/project_form.html.twig', [
'action_url' => $CFG_GLPI['root_doc'] . '/plugins/redmine/front/project.form.php', 'action_url' => $CFG_GLPI['root_doc'] . '/plugins/redmine/front/project.form.php',
'projects_id' => $projects_id, 'owner_field' => 'projects_id',
'owner_id' => $projects_id,
'redmine_projects' => $redmine_projects, 'redmine_projects' => $redmine_projects,
'project_name' => $project->fields['name'], 'project_name' => $project->fields['name'],
'project_description' => $description, 'project_description' => $description,

View file

@ -1,6 +1,6 @@
<?php <?php
define('PLUGIN_REDMINE_VERSION', '1.3.0'); define('PLUGIN_REDMINE_VERSION', '1.4.0');
/** /**
* Init hooks of the plugin. * Init hooks of the plugin.
@ -17,6 +17,9 @@ function plugin_init_redmine() {
Plugin::registerClass('PluginRedmineProject', [ Plugin::registerClass('PluginRedmineProject', [
'addtabon' => ['Project'] 'addtabon' => ['Project']
]); ]);
Plugin::registerClass('PluginRedmineEntity', [
'addtabon' => ['Entity']
]);
// Set configuration page // Set configuration page
$PLUGIN_HOOKS['config_page']['redmine'] = 'front/config.form.php'; $PLUGIN_HOOKS['config_page']['redmine'] = 'front/config.form.php';

View file

@ -1,7 +1,7 @@
{% import 'components/form/fields_macros.html.twig' as fields %} {% import 'components/form/fields_macros.html.twig' as fields %}
<form action="{{ action_url }}" method="post" class="mt-4" data-ajax="false"> <form action="{{ action_url }}" method="post" class="mt-4" data-ajax="false">
<input type="hidden" name="projects_id" value="{{ projects_id }}"> <input type="hidden" name="{{ owner_field }}" value="{{ owner_id }}">
<input type="hidden" name="_glpi_csrf_token" value="{{ csrf_token_value }}"> <input type="hidden" name="_glpi_csrf_token" value="{{ csrf_token_value }}">
<input type="hidden" name="_glpi_simple_form" value="1"> <input type="hidden" name="_glpi_simple_form" value="1">

View file

@ -7,7 +7,7 @@
<form method="post" action="{{ action_url }}" class="mt-2"> <form method="post" action="{{ action_url }}" class="mt-2">
<input type="hidden" name="_glpi_csrf_token" value="{{ csrf_token_value }}"> <input type="hidden" name="_glpi_csrf_token" value="{{ csrf_token_value }}">
<input type="hidden" name="projects_id" value="{{ projects_id }}"> <input type="hidden" name="{{ owner_field }}" value="{{ owner_id }}">
<input type="hidden" name="action" value="set_category"> <input type="hidden" name="action" value="set_category">
<div class="card"> <div class="card">