- 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>
437 lines
16 KiB
PHP
437 lines
16 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Install hook
|
|
*
|
|
* @return boolean
|
|
*/
|
|
function plugin_redmine_install() {
|
|
global $DB;
|
|
|
|
$migration = new \Migration(PLUGIN_REDMINE_VERSION);
|
|
|
|
// Config table for storing Redmine URL and API Key
|
|
if (!$DB->tableExists("glpi_plugin_redmine_configs")) {
|
|
$query = "CREATE TABLE `glpi_plugin_redmine_configs` (
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
`redmine_url` varchar(255) DEFAULT NULL,
|
|
`api_key` varchar(255) DEFAULT NULL,
|
|
`metadata_cache` LONGTEXT DEFAULT NULL,
|
|
`default_tracker_id` int(11) NOT NULL DEFAULT 0,
|
|
`default_priority_id` int(11) NOT NULL DEFAULT 0,
|
|
`default_activity_id` int(11) NOT NULL DEFAULT 0,
|
|
`default_role_id` int(11) NOT NULL DEFAULT 0,
|
|
`default_watcher_ids` TEXT DEFAULT NULL,
|
|
`status_map` TEXT DEFAULT NULL,
|
|
PRIMARY KEY (`id`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
|
|
$migration->addPostQuery($query);
|
|
|
|
$migration->addPostQuery("INSERT INTO `glpi_plugin_redmine_configs` (`id`, `redmine_url`, `api_key`) VALUES (1, '', '')");
|
|
} else {
|
|
if (!$DB->fieldExists("glpi_plugin_redmine_configs", "metadata_cache")) {
|
|
$migration->addField('glpi_plugin_redmine_configs', 'metadata_cache', 'LONGTEXT');
|
|
}
|
|
// Default values for Redmine issue creation (formconfig).
|
|
$new_fields = [
|
|
'default_tracker_id' => "int NOT NULL DEFAULT '0'",
|
|
'default_priority_id' => "int NOT NULL DEFAULT '0'",
|
|
'default_activity_id' => "int NOT NULL DEFAULT '0'",
|
|
'default_role_id' => "int NOT NULL DEFAULT '0'",
|
|
'default_watcher_ids' => "text",
|
|
'status_map' => "text",
|
|
];
|
|
foreach ($new_fields as $field => $type) {
|
|
if (!$DB->fieldExists('glpi_plugin_redmine_configs', $field)) {
|
|
$migration->addField('glpi_plugin_redmine_configs', $field, $type);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mapping table for GLPI Projects <-> Redmine Projects
|
|
if (!$DB->tableExists("glpi_plugin_redmine_projects")) {
|
|
$query = "CREATE TABLE `glpi_plugin_redmine_projects` (
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
`projects_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 `projects_id` (`projects_id`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
|
|
$migration->addPostQuery($query);
|
|
} else {
|
|
if (!$DB->fieldExists('glpi_plugin_redmine_projects', 'default_category_id')) {
|
|
$migration->addField('glpi_plugin_redmine_projects', 'default_category_id', "int NOT NULL DEFAULT '0'");
|
|
}
|
|
}
|
|
|
|
// Mapping table for GLPI Tickets <-> Redmine Issues (one issue per ticket).
|
|
// Prevents creating a duplicate issue every time a TicketTask is saved.
|
|
if (!$DB->tableExists("glpi_plugin_redmine_tickets")) {
|
|
$query = "CREATE TABLE `glpi_plugin_redmine_tickets` (
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
`tickets_id` int(11) NOT NULL,
|
|
`redmine_issue_id` int(11) NOT NULL,
|
|
PRIMARY KEY (`id`),
|
|
UNIQUE KEY `tickets_id` (`tickets_id`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
|
|
$migration->addPostQuery($query);
|
|
}
|
|
|
|
// Mapping table for GLPI TicketTasks <-> Redmine time entries.
|
|
// Guarantees a task logs its time only once (no double-count on update).
|
|
if (!$DB->tableExists("glpi_plugin_redmine_tasks")) {
|
|
$query = "CREATE TABLE `glpi_plugin_redmine_tasks` (
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
`tickettasks_id` int(11) NOT NULL,
|
|
`redmine_time_entry_id` int(11) NOT NULL,
|
|
PRIMARY KEY (`id`),
|
|
UNIQUE KEY `tickettasks_id` (`tickettasks_id`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
|
|
$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").
|
|
if (!$DB->tableExists("glpi_plugin_redmine_users")) {
|
|
$query = "CREATE TABLE `glpi_plugin_redmine_users` (
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
`redmine_id` int(11) NOT NULL,
|
|
`login` varchar(255) DEFAULT NULL,
|
|
`mail` varchar(255) DEFAULT NULL,
|
|
`name` varchar(255) DEFAULT NULL,
|
|
PRIMARY KEY (`id`),
|
|
UNIQUE KEY `redmine_id` (`redmine_id`),
|
|
KEY `mail` (`mail`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
|
|
$migration->addPostQuery($query);
|
|
}
|
|
|
|
// Manual overrides: GLPI user -> Redmine user (used when e-mail auto-match
|
|
// is not enough). Email auto-match is resolved at runtime against the cache.
|
|
if (!$DB->tableExists("glpi_plugin_redmine_usermap")) {
|
|
$query = "CREATE TABLE `glpi_plugin_redmine_usermap` (
|
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
`users_id` int(11) NOT NULL,
|
|
`redmine_user_id` int(11) NOT NULL,
|
|
PRIMARY KEY (`id`),
|
|
UNIQUE KEY `users_id` (`users_id`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
|
|
$migration->addPostQuery($query);
|
|
}
|
|
|
|
$migration->executeMigration();
|
|
|
|
// One-time migration: encrypt any API key still stored in plain text.
|
|
// GLPIKey::decrypt() returns '' for a value that was not encrypted by it,
|
|
// so a non-empty key that decrypts to empty is plain text and must be cifered.
|
|
if ($DB->tableExists("glpi_plugin_redmine_configs")) {
|
|
$it = $DB->request(['FROM' => 'glpi_plugin_redmine_configs', 'WHERE' => ['id' => 1]]);
|
|
if (count($it) > 0) {
|
|
$row = $it->current();
|
|
$stored = (string) ($row['api_key'] ?? '');
|
|
if ($stored !== '') {
|
|
$key = new \GLPIKey();
|
|
$decoded = (string) $key->decrypt($stored);
|
|
if ($decoded === '') {
|
|
$DB->update('glpi_plugin_redmine_configs', [
|
|
'api_key' => $key->encrypt($stored)
|
|
], ['id' => 1]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Uninstall hook
|
|
*
|
|
* @return boolean
|
|
*/
|
|
function plugin_redmine_uninstall() {
|
|
global $DB;
|
|
|
|
$tables = [
|
|
"glpi_plugin_redmine_configs",
|
|
"glpi_plugin_redmine_projects",
|
|
"glpi_plugin_redmine_tickets",
|
|
"glpi_plugin_redmine_tasks",
|
|
"glpi_plugin_redmine_users",
|
|
"glpi_plugin_redmine_usermap",
|
|
"glpi_plugin_redmine_entities"
|
|
];
|
|
|
|
foreach ($tables as $table) {
|
|
if ($DB->tableExists($table)) {
|
|
$DB->dropTable($table);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Hook for TicketTask creation
|
|
*/
|
|
function plugin_redmine_tickettask_add(TicketTask $task) {
|
|
_plugin_redmine_process_tickettask($task);
|
|
}
|
|
|
|
/**
|
|
* Hook for TicketTask update
|
|
*/
|
|
function plugin_redmine_tickettask_update(TicketTask $task) {
|
|
_plugin_redmine_process_tickettask($task);
|
|
}
|
|
|
|
/**
|
|
* Hook for Ticket update: keep the linked Redmine issue status in sync.
|
|
* Only acts if an issue already exists for this ticket (created on first done task).
|
|
*/
|
|
function plugin_redmine_ticket_update(Ticket $ticket) {
|
|
global $DB;
|
|
|
|
$ticketId = (int) $ticket->getID();
|
|
$issueMap = $DB->request([
|
|
'FROM' => 'glpi_plugin_redmine_tickets',
|
|
'WHERE' => ['tickets_id' => $ticketId]
|
|
]);
|
|
if (count($issueMap) === 0) {
|
|
return; // No Redmine issue yet for this ticket
|
|
}
|
|
$issueId = (int) $issueMap->current()['redmine_issue_id'];
|
|
|
|
$mappedStatus = PluginRedmineConfig::mapTicketStatus((int) $ticket->fields['status']);
|
|
if ($mappedStatus) {
|
|
PluginRedmineRedmineapi::updateIssue($issueId, ['status_id' => $mappedStatus]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return the first assigned technician (users_id) of a ticket, or 0.
|
|
*/
|
|
function _plugin_redmine_assigned_tech($ticketId) {
|
|
global $DB;
|
|
$it = $DB->request([
|
|
'SELECT' => 'users_id',
|
|
'FROM' => 'glpi_tickets_users',
|
|
'WHERE' => [
|
|
'tickets_id' => (int) $ticketId,
|
|
'type' => \CommonITILActor::ASSIGN,
|
|
['users_id' => ['>', 0]],
|
|
],
|
|
'LIMIT' => 1,
|
|
]);
|
|
return count($it) > 0 ? (int) $it->current()['users_id'] : 0;
|
|
}
|
|
|
|
/**
|
|
* Resolve a GLPI user to a Redmine login for impersonation, ensuring the user
|
|
* is a member of the Redmine project (so impersonation is allowed).
|
|
* @return string|null the Redmine login, or null if no mapping
|
|
*/
|
|
function _plugin_redmine_impersonation_login($glpi_users_id, $redmineProjectId) {
|
|
if (empty($glpi_users_id)) {
|
|
return null;
|
|
}
|
|
$ru = PluginRedmineConfig::resolveRedmineUser($glpi_users_id);
|
|
if (!$ru || empty($ru['login'])) {
|
|
return null;
|
|
}
|
|
$roleId = PluginRedmineConfig::getDefaultRoleId();
|
|
if ($roleId) {
|
|
PluginRedmineRedmineapi::ensureProjectMembership($redmineProjectId, (int) $ru['redmine_id'], [$roleId]);
|
|
}
|
|
return $ru['login'];
|
|
}
|
|
|
|
/**
|
|
* Internal logic for processing TicketTasks and sending time to Redmine
|
|
*/
|
|
function _plugin_redmine_process_tickettask(TicketTask $task) {
|
|
// Time is only sent to Redmine once the task is concluded (state = Done).
|
|
// While not done, the task lives only in GLPI.
|
|
if ((int) ($task->fields['state'] ?? 0) !== \Planning::DONE) {
|
|
return;
|
|
}
|
|
|
|
if (!isset($task->fields['actiontime']) || $task->fields['actiontime'] <= 0) {
|
|
return; // No time logged
|
|
}
|
|
|
|
global $DB;
|
|
|
|
$taskId = (int) $task->getID();
|
|
$ticketId = (int) $task->fields['tickets_id'];
|
|
|
|
// Time entry is logged only once per task: if we already recorded it,
|
|
// a later update must not create another Redmine time entry.
|
|
$logged = $DB->request([
|
|
'FROM' => 'glpi_plugin_redmine_tasks',
|
|
'WHERE' => ['tickettasks_id' => $taskId]
|
|
]);
|
|
if (count($logged) > 0) {
|
|
return;
|
|
}
|
|
|
|
$ticket = new Ticket();
|
|
if (!$ticket->getFromDB($ticketId)) {
|
|
return;
|
|
}
|
|
|
|
// Resolve the GLPI project linked to this ticket.
|
|
// GLPI 11 links tickets to projects either through a project task
|
|
// (glpi_projecttasks_tickets) or directly (glpi_projects_tickets).
|
|
$iterator = $DB->request([
|
|
'FROM' => 'glpi_projecttasks_tickets',
|
|
'WHERE' => ['tickets_id' => $ticketId]
|
|
]);
|
|
|
|
$projectId = null;
|
|
if (count($iterator) > 0) {
|
|
$link = $iterator->current();
|
|
$pt = new ProjectTask();
|
|
if ($pt->getFromDB($link['projecttasks_id'])) {
|
|
$projectId = (int) $pt->fields['projects_id'];
|
|
}
|
|
} else {
|
|
// Direct link Project <-> Ticket (GLPI 11 stores it polymorphically
|
|
// in glpi_itils_projects with itemtype = 'Ticket').
|
|
$iterator = $DB->request([
|
|
'FROM' => 'glpi_itils_projects',
|
|
'WHERE' => ['itemtype' => 'Ticket', 'items_id' => $ticketId]
|
|
]);
|
|
if (count($iterator) > 0) {
|
|
$link = $iterator->current();
|
|
$projectId = (int) $link['projects_id'];
|
|
}
|
|
}
|
|
|
|
// Resolve the Redmine project. Priority: the linked GLPI project; if none,
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
// Entity-based clients (recurring support): ticket born in a linked entity, no project.
|
|
if (!$redmineProjectId) {
|
|
$entMap = $DB->request([
|
|
'FROM' => 'glpi_plugin_redmine_entities',
|
|
'WHERE' => ['entities_id' => (int) $ticket->fields['entities_id']]
|
|
]);
|
|
if (count($entMap) > 0) {
|
|
$linkRow = $entMap->current();
|
|
$redmineProjectId = (int) $linkRow['redmine_project_id'];
|
|
}
|
|
}
|
|
|
|
if (!$redmineProjectId) {
|
|
Toolbox::logInFile('redmine', "No Redmine link for ticket #$ticketId (no project nor entity link).\n");
|
|
return;
|
|
}
|
|
|
|
// Reuse a single Redmine issue per GLPI ticket.
|
|
$issueId = null;
|
|
$issueMap = $DB->request([
|
|
'FROM' => 'glpi_plugin_redmine_tickets',
|
|
'WHERE' => ['tickets_id' => $ticketId]
|
|
]);
|
|
if (count($issueMap) > 0) {
|
|
$issueId = (int) $issueMap->current()['redmine_issue_id'];
|
|
} else {
|
|
// Build the issue from the ticket + the configured defaults.
|
|
$cfg = PluginRedmineConfig::getConfigRow();
|
|
$extra = [];
|
|
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']; }
|
|
|
|
// Category is per link (project or entity).
|
|
if ($linkRow && !empty($linkRow['default_category_id'])) {
|
|
$extra['category_id'] = (int) $linkRow['default_category_id'];
|
|
}
|
|
|
|
// Issue status mirrors the GLPI ticket status (mapped).
|
|
$mappedStatus = PluginRedmineConfig::mapTicketStatus((int) $ticket->fields['status']);
|
|
if ($mappedStatus) { $extra['status_id'] = $mappedStatus; }
|
|
|
|
// Default watchers come from the config (Redmine user ids).
|
|
$watchers = PluginRedmineConfig::getDefaultWatcherIds();
|
|
if (!empty($watchers)) { $extra['watcher_user_ids'] = $watchers; }
|
|
|
|
// Author = the GLPI assigned technician, via impersonation.
|
|
$techId = _plugin_redmine_assigned_tech($ticketId);
|
|
$switchUser = _plugin_redmine_impersonation_login($techId, $redmineProjectId);
|
|
|
|
// Subject = ticket title; description stripped of HTML.
|
|
$description = strip_tags(html_entity_decode((string) $ticket->fields['content']));
|
|
$issueId = PluginRedmineRedmineapi::createIssue(
|
|
$redmineProjectId,
|
|
$ticket->fields['name'],
|
|
$description,
|
|
$extra,
|
|
$switchUser
|
|
);
|
|
if ($issueId) {
|
|
$DB->insert('glpi_plugin_redmine_tickets', [
|
|
'tickets_id' => $ticketId,
|
|
'redmine_issue_id' => $issueId
|
|
]);
|
|
}
|
|
}
|
|
|
|
if (!$issueId) {
|
|
Toolbox::logInFile('redmine', "Failed to resolve Redmine issue for ticket #$ticketId\n");
|
|
return;
|
|
}
|
|
|
|
$cfg = PluginRedmineConfig::getConfigRow();
|
|
$hours = round($task->fields['actiontime'] / HOUR_TIMESTAMP, 2);
|
|
|
|
// Time entry author = the GLPI user who logged the task, via impersonation.
|
|
$taskUserId = (int) ($task->fields['users_id'] ?? 0);
|
|
$timeSwitchUser = _plugin_redmine_impersonation_login($taskUserId, $redmineProjectId);
|
|
|
|
$timeEntryId = PluginRedmineRedmineapi::logTime(
|
|
$issueId,
|
|
$hours,
|
|
strip_tags(html_entity_decode($task->fields['content'])),
|
|
!empty($cfg['default_activity_id']) ? (int) $cfg['default_activity_id'] : null,
|
|
$timeSwitchUser
|
|
);
|
|
|
|
if ($timeEntryId) {
|
|
$DB->insert('glpi_plugin_redmine_tasks', [
|
|
'tickettasks_id' => $taskId,
|
|
'redmine_time_entry_id' => $timeEntryId
|
|
]);
|
|
}
|
|
}
|