Redmine/hook.php
Gemini 4214bf4814 fix: lançamento de tempo com comentário-fallback (campo obrigatório no Redmine) (1.5.4)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:10:38 -03:00

443 lines
17 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);
// Comments are mandatory on time entries in some Redmine setups — never send empty.
$comments = trim(strip_tags(html_entity_decode((string) ($task->fields['content'] ?? ''))));
if ($comments === '') {
$comments = "Tempo lançado via GLPI (chamado #{$ticketId})";
}
// 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,
$comments,
!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
]);
}
}