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) { $candidate = (int) $issueMap->current()['redmine_issue_id']; if (PluginRedmineRedmineapi::issueExists($candidate)) { $issueId = $candidate; } else { // A issue foi apagada no Redmine: remove o vínculo órfão e recria. Toolbox::logInFile('redmine', "Issue #{$candidate} do ticket #{$ticketId} não existe mais; recriando.\n"); $DB->delete('glpi_plugin_redmine_tickets', ['tickets_id' => $ticketId]); } } if (!$issueId) { // 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 ]); } }