From 80d1576a809085e0bf3b1e5a92bb38ec3a210916 Mon Sep 17 00:00:00 2001 From: Gemini Date: Mon, 29 Jun 2026 18:32:19 -0300 Subject: [PATCH] Redmine Integration 1.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin de integração GLPI <-> Redmine: - Config nativa (Twig) com API key cifrada (GLPIKey) - Vínculo projeto GLPI <-> projeto Redmine (aba do projeto) + categoria por vínculo - 1 issue Redmine por chamado; defaults (tracker/priority/activity) e mapa de status - Tempo das TicketTasks -> time entries (gate em "Feito"), idempotente - Autores via impersonation (X-Redmine-Switch-User) + auto-membership por role - Mapeamento de usuários GLPI->Redmine (auto-match por e-mail + overrides) Co-Authored-By: Claude Opus 4.8 --- .gitignore | 7 + front/config.form.php | 54 ++++ front/project.form.php | 88 +++++++ hook.php | 410 +++++++++++++++++++++++++++++ inc/config.class.php | 206 +++++++++++++++ inc/event.class.php | 4 + inc/project.class.php | 104 ++++++++ inc/redmineapi.class.php | 400 ++++++++++++++++++++++++++++ logo.png | Bin 0 -> 1050 bytes setup.php | 82 ++++++ templates/config_form.html.twig | 135 ++++++++++ templates/project_form.html.twig | 73 +++++ templates/project_linked.html.twig | 38 +++ 13 files changed, 1601 insertions(+) create mode 100644 .gitignore create mode 100644 front/config.form.php create mode 100644 front/project.form.php create mode 100644 hook.php create mode 100644 inc/config.class.php create mode 100644 inc/event.class.php create mode 100644 inc/project.class.php create mode 100644 inc/redmineapi.class.php create mode 100644 logo.png create mode 100644 setup.php create mode 100644 templates/config_form.html.twig create mode 100644 templates/project_form.html.twig create mode 100644 templates/project_linked.html.twig diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c89b7a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# OS / editores +.DS_Store +*~ +*.swp +# Dependências / build (não usadas hoje, mas por garantia) +/vendor/ +/node_modules/ diff --git a/front/config.form.php b/front/config.form.php new file mode 100644 index 0000000..74a18f1 --- /dev/null +++ b/front/config.form.php @@ -0,0 +1,54 @@ +update($_POST); + + if (isset($_POST["sync"])) { + $metadata = PluginRedmineRedmineapi::syncMetadata(); + if ($metadata !== false) { + $DB->update('glpi_plugin_redmine_configs', [ + 'metadata_cache' => json_encode($metadata) + ], ['id' => 1]); + PluginRedmineRedmineapi::syncUsers(); // refresh Redmine user cache + Session::addMessageAfterRedirect(__('Metadados sincronizados com sucesso!', 'redmine')); + } else { + Session::addMessageAfterRedirect(__('Erro ao sincronizar com o Redmine. Verifique URL e chave.', 'redmine'), false, ERROR); + } + } + + Html::back(); +} + +// Add a manual user mapping override (GLPI user -> Redmine user). +if (isset($_POST["add_usermap"])) { + $uid = (int) ($_POST['override_users_id'] ?? 0); + $rid = (int) ($_POST['override_redmine_user_id'] ?? 0); + if ($uid > 0 && $rid > 0) { + $DB->delete('glpi_plugin_redmine_usermap', ['users_id' => $uid]); // replace existing + $DB->insert('glpi_plugin_redmine_usermap', ['users_id' => $uid, 'redmine_user_id' => $rid]); + Session::addMessageAfterRedirect(__('Mapeamento adicionado.', 'redmine')); + } else { + Session::addMessageAfterRedirect(__('Selecione um usuário GLPI e um usuário Redmine.', 'redmine'), false, ERROR); + } + Html::back(); +} + +// Remove a manual user mapping override. +if (isset($_POST["del_usermap"])) { + $DB->delete('glpi_plugin_redmine_usermap', ['id' => (int) $_POST['del_usermap']]); + Session::addMessageAfterRedirect(__('Mapeamento removido.', 'redmine')); + Html::back(); +} + +Html::header('Redmine Config', $_SERVER['PHP_SELF'], "config", "plugins"); + +$config->showForm(1); + +Html::footer(); diff --git a/front/project.form.php b/front/project.form.php new file mode 100644 index 0000000..faaae51 --- /dev/null +++ b/front/project.form.php @@ -0,0 +1,88 @@ +getFromDB($projects_id) || !$project->can($projects_id, UPDATE)) { + Html::displayRightError(); + } + + if ($action === 'sync') { + // Fetch projects from Redmine and cache them + $redmine_projects = PluginRedmineRedmineapi::getProjects(); + if ($redmine_projects !== false) { + global $DB; + $metadata = ['projects' => $redmine_projects]; + + $DB->update('glpi_plugin_redmine_configs', [ + 'metadata_cache' => json_encode($metadata) + ], [ + 'id' => 1 + ]); + Session::addMessageAfterRedirect("Metadados sincronizados com sucesso!"); + } else { + Session::addMessageAfterRedirect("Erro ao sincronizar com Redmine.", false, ERROR); + } + } + elseif ($action === 'set_category') { + global $DB; + $DB->update('glpi_plugin_redmine_projects', [ + 'default_category_id' => (int) ($_POST['default_category_id'] ?? 0) + ], [ + 'projects_id' => $projects_id + ]); + Session::addMessageAfterRedirect("Categoria padrão salva com sucesso!"); + } + elseif ($action === 'process') { + $redmine_project_id = $_POST['redmine_project_id'] ?? ''; + + if (!empty($redmine_project_id)) { + // LINK OPERATION + global $DB; + $DB->insert('glpi_plugin_redmine_projects', [ + 'projects_id' => $projects_id, + 'redmine_project_id' => $redmine_project_id + ]); + Session::addMessageAfterRedirect("Projeto vinculado com sucesso!"); + } 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) { + global $DB; + $DB->insert('glpi_plugin_redmine_projects', [ + 'projects_id' => $projects_id, + 'redmine_project_id' => $new_redmine_id + ]); + Session::addMessageAfterRedirect("Projeto criado e vinculado com sucesso!"); + } else { + Session::addMessageAfterRedirect("Erro ao criar projeto no Redmine.", false, ERROR); + } + } + } + + Html::redirect($CFG_GLPI["root_doc"] . "/front/project.form.php?id=" . $projects_id); +} + +Html::displayErrorAndDie("Lost"); diff --git a/hook.php b/hook.php new file mode 100644 index 0000000..f24f0e0 --- /dev/null +++ b/hook.php @@ -0,0 +1,410 @@ +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); + } + + // 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" + ]; + + 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']; + } + } + + if (!$projectId) { + return; // Not linked to a project + } + + // Source of truth for the Redmine project is the link saved by the + // Project tab (it may use a custom identifier or an existing project). + $redmineProjectId = null; + $projRow = null; + $mapping = $DB->request([ + 'FROM' => 'glpi_plugin_redmine_projects', + 'WHERE' => ['projects_id' => $projectId] + ]); + if (count($mapping) > 0) { + $projRow = $mapping->current(); + $redmineProjectId = (int) $projRow['redmine_project_id']; + } else { + // Fallback: resolve by the conventional identifier. + $redmineProjectId = PluginRedmineRedmineapi::getProjectByIdentifier("glpi-proj-" . $projectId); + } + + if (!$redmineProjectId) { + Toolbox::logInFile('redmine', "Redmine project not found for GLPI project ID $projectId\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 project link. + if ($projRow && !empty($projRow['default_category_id'])) { + $extra['category_id'] = (int) $projRow['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 + ]); + } +} diff --git a/inc/config.class.php b/inc/config.class.php new file mode 100644 index 0000000..0594726 --- /dev/null +++ b/inc/config.class.php @@ -0,0 +1,206 @@ +encrypt((string) $input['api_key']); + } + } + // The status map is posted as status_map[] = . + if (isset($input['status_map']) && is_array($input['status_map'])) { + $input['status_map'] = json_encode($input['status_map']); + } + // Default watchers come from a multiselect (array of Redmine user ids). + if (isset($input['default_watcher_ids'])) { + $ids = is_array($input['default_watcher_ids']) ? $input['default_watcher_ids'] : []; + $input['default_watcher_ids'] = json_encode(array_values(array_filter(array_map('intval', $ids)))); + } + return $input; + } + + /** + * Return the (single) config row. + * @return array + */ + public static function getConfigRow() { + global $DB; + $it = $DB->request(['FROM' => 'glpi_plugin_redmine_configs', 'WHERE' => ['id' => 1]]); + return count($it) > 0 ? $it->current() : []; + } + + /** + * Resolve the Redmine user for a GLPI user. + * Priority: manual override (usermap) -> e-mail auto-match against the cache. + * @param int $glpi_users_id + * @return array|null Redmine user cache row [redmine_id, login, mail, name] or null + */ + public static function resolveRedmineUser($glpi_users_id) { + global $DB; + $glpi_users_id = (int) $glpi_users_id; + if ($glpi_users_id <= 0) { + return null; + } + + // 1) Manual override + $ov = $DB->request([ + 'FROM' => 'glpi_plugin_redmine_usermap', + 'WHERE' => ['users_id' => $glpi_users_id] + ]); + if (count($ov) > 0) { + $rid = (int) $ov->current()['redmine_user_id']; + $ru = $DB->request(['FROM' => 'glpi_plugin_redmine_users', 'WHERE' => ['redmine_id' => $rid]]); + if (count($ru) > 0) { + return $ru->current(); + } + } + + // 2) E-mail auto-match + $email = \UserEmail::getDefaultForUser($glpi_users_id); + if (!empty($email)) { + $ru = $DB->request(['FROM' => 'glpi_plugin_redmine_users', 'WHERE' => ['mail' => $email]]); + if (count($ru) > 0) { + return $ru->current(); + } + } + + return null; + } + + /** + * @return int default Redmine role id used for auto-membership + */ + public static function getDefaultRoleId() { + return (int) (self::getConfigRow()['default_role_id'] ?? 0); + } + + /** + * @return int[] configured default watcher Redmine user ids + */ + public static function getDefaultWatcherIds() { + $raw = self::getConfigRow()['default_watcher_ids'] ?? ''; + if (empty($raw)) { + return []; + } + $ids = json_decode($raw, true); + return is_array($ids) ? array_values(array_map('intval', $ids)) : []; + } + + /** + * Map a GLPI ticket status to the configured Redmine status id. + * @param int $glpi_status + * @return int|null + */ + public static function mapTicketStatus($glpi_status) { + $row = self::getConfigRow(); + if (empty($row['status_map'])) { + return null; + } + $map = json_decode($row['status_map'], true); + if (!is_array($map) || empty($map[$glpi_status])) { + return null; + } + return (int) $map[$glpi_status]; + } + + /** + * Build a {id: name} options map from a Redmine metadata list. + * @param array $list + * @return array + */ + private static function toOptions($list) { + $opt = []; + foreach (($list ?? []) as $el) { + if (isset($el['id'], $el['name'])) { + $opt[$el['id']] = $el['name']; + } + } + return $opt; + } + + /** + * Show config form + * @param int $ID + * @param array $options + * @return boolean + */ + function showForm($ID, array $options = []) { + global $DB; + + // Fetch config + $config = []; + $iterator = $DB->request([ + 'FROM' => 'glpi_plugin_redmine_configs', + 'WHERE' => ['id' => 1] + ]); + if (count($iterator) > 0) { + $config = $iterator->current(); + } + + $metadata = !empty($config['metadata_cache']) ? json_decode($config['metadata_cache'], true) : []; + $status_map = !empty($config['status_map']) ? json_decode($config['status_map'], true) : []; + $watcher_ids = self::getDefaultWatcherIds(); + + // Redmine users (from cache) -> "Nome (login)" options + $redmine_user_options = []; + foreach ($DB->request(['FROM' => 'glpi_plugin_redmine_users', 'ORDER' => 'name']) as $u) { + $redmine_user_options[$u['redmine_id']] = trim($u['name']) . ' (' . $u['login'] . ')'; + } + + // Existing manual overrides (GLPI user -> Redmine user) with display names + $usermap = []; + foreach ($DB->request(['FROM' => 'glpi_plugin_redmine_usermap']) as $row) { + $usermap[] = [ + 'id' => $row['id'], + 'users_id' => $row['users_id'], + 'glpi_name' => getUserName($row['users_id']), + 'redmine_name' => $redmine_user_options[$row['redmine_user_id']] ?? ('#' . $row['redmine_user_id']), + ]; + } + + \Glpi\Application\View\TemplateRenderer::getInstance()->display('@redmine/config_form.html.twig', [ + 'action_url' => Toolbox::getItemTypeFormURL(__CLASS__), + 'redmine_url' => $config['redmine_url'] ?? '', + 'has_key' => !empty($config['api_key']), + 'has_metadata' => !empty($metadata['trackers']) || !empty($metadata['statuses']), + 'tracker_options' => self::toOptions($metadata['trackers'] ?? []), + 'priority_options' => self::toOptions($metadata['priorities'] ?? []), + 'activity_options' => self::toOptions($metadata['activities'] ?? []), + 'status_options' => self::toOptions($metadata['statuses'] ?? []), + 'role_options' => self::toOptions($metadata['roles'] ?? []), + 'redmine_user_options' => $redmine_user_options, + 'default_tracker_id' => (int) ($config['default_tracker_id'] ?? 0), + 'default_priority_id' => (int) ($config['default_priority_id'] ?? 0), + 'default_activity_id' => (int) ($config['default_activity_id'] ?? 0), + 'default_role_id' => (int) ($config['default_role_id'] ?? 0), + 'default_watcher_ids' => $watcher_ids, + 'status_map' => is_array($status_map) ? $status_map : [], + 'glpi_ticket_statuses' => \Ticket::getAllStatusArray(), + 'usermap' => $usermap, + ]); + + return true; + } +} diff --git a/inc/event.class.php b/inc/event.class.php new file mode 100644 index 0000000..5f406aa --- /dev/null +++ b/inc/event.class.php @@ -0,0 +1,4 @@ +getType() == 'Project') { + return self::createTabEntry(__('Redmine', 'redmine'), 0, $item::getType(), 'fas fa-sitemap'); + } + return ''; + } + + static function displayTabContentForItem(CommonGLPI $item, $tabnum = 1, $withtemplate = 0) { + if ($item->getType() == 'Project') { + self::showForProject($item); + } + return true; + } + + static function getIcon() { + return 'fas fa-sitemap'; + } + + static function showForProject(Project $project) { + global $DB, $CFG_GLPI; + + $projects_id = $project->getID(); + + // Check if already linked + $iterator = $DB->request([ + 'FROM' => 'glpi_plugin_redmine_projects', + 'WHERE' => ['projects_id' => $projects_id] + ]); + + if (count($iterator) > 0) { + $link = $iterator->current(); + $redmineProjectId = (int) $link['redmine_project_id']; + + // Issue categories are per Redmine project. + $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' => $CFG_GLPI['root_doc'] . '/plugins/redmine/front/project.form.php', + 'projects_id' => $projects_id, + 'redmine_project_id' => $redmineProjectId, + 'cat_options' => $cat_options, + 'default_category_id' => (int) ($link['default_category_id'] ?? 0), + 'csrf_token_value' => Session::getNewCSRFToken(), + ]); + return; + } + + // Fetch Redmine Metadata Cache + $config_it = $DB->request(['FROM' => 'glpi_plugin_redmine_configs', 'WHERE' => ['id' => 1]]); + $config = count($config_it) > 0 ? $config_it->current() : []; + $metadata = []; + if (!empty($config['metadata_cache'])) { + $metadata = 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' + ]; + + // Strip HTML from description + $description = \Glpi\RichText\RichText::getTextFromHtml($project->fields['content'], false, true, true); + + global $DB, $CFG_GLPI; + + \Glpi\Application\View\TemplateRenderer::getInstance()->display('@redmine/project_form.html.twig', [ + 'action_url' => $CFG_GLPI['root_doc'] . '/plugins/redmine/front/project.form.php', + 'projects_id' => $projects_id, + 'redmine_projects' => $redmine_projects, + 'project_name' => $project->fields['name'], + 'project_description' => $description, + 'project_identifier' => 'glpi-proj-' . $projects_id, + 'modules' => $modules, + 'csrf_token_value' => Session::getNewCSRFToken() + ]); + } +} diff --git a/inc/redmineapi.class.php b/inc/redmineapi.class.php new file mode 100644 index 0000000..9569fc1 --- /dev/null +++ b/inc/redmineapi.class.php @@ -0,0 +1,400 @@ +request([ + 'FROM' => 'glpi_plugin_redmine_configs', + 'WHERE' => ['id' => 1] + ]); + if (count($iterator) > 0) { + $config = $iterator->current(); + // The API key is stored encrypted with GLPIKey; decrypt for use. + if (!empty($config['api_key'])) { + $config['api_key'] = (string) (new \GLPIKey())->decrypt($config['api_key']); + } + return $config; + } + return false; + } + + /** + * Perform HTTP POST request to Redmine API + * @param string $endpoint + * @param array $payload + * @return array|false + */ + private static function post($endpoint, $payload, $switchUser = null) { + $config = self::getConfig(); + if (!$config || empty($config['redmine_url']) || empty($config['api_key'])) { + Toolbox::logError("Redmine Plugin: URL or API Key not configured."); + return false; + } + + $url = rtrim($config['redmine_url'], '/') . $endpoint; + $headers = [ + 'Content-Type: application/json', + 'X-Redmine-API-Key: ' . $config['api_key'] + ]; + // Impersonation: create the object as the given Redmine user (admin only). + if (!empty($switchUser)) { + $headers[] = 'X-Redmine-Switch-User: ' . $switchUser; + } + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); // Prevent 504 gateway timeout + + $response = curl_exec($ch); + $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpcode >= 200 && $httpcode < 300) { + return json_decode($response, true); + } else { + Toolbox::logError("Redmine Plugin API Error: [$httpcode] $response"); + return false; + } + } + + /** + * Perform HTTP GET request to Redmine API + * @param string $endpoint + * @return array|false + */ + private static function get($endpoint) { + $config = self::getConfig(); + if (!$config || empty($config['redmine_url']) || empty($config['api_key'])) { + return false; + } + + $url = rtrim($config['redmine_url'], '/') . $endpoint; + $headers = [ + 'Content-Type: application/json', + 'X-Redmine-API-Key: ' . $config['api_key'] + ]; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); // Prevent 504 gateway timeout + + $response = curl_exec($ch); + $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpcode >= 200 && $httpcode < 300) { + return json_decode($response, true); + } + return false; + } + + /** + * Create a Redmine Project + * @param string $name + * @param string $identifier + * @return int|false Returns project ID on success + */ + public static function createProject($name, $identifier) { + // Redmine identifier max length is 100, must be lower case, a-z, 0-9 and dashes. + $identifier = strtolower(preg_replace('/[^a-zA-Z0-9\-]/', '-', $identifier)); + + $payload = [ + 'project' => [ + 'name' => $name, + 'identifier' => $identifier + ] + ]; + + $result = self::post('/projects.json', $payload); + if ($result && isset($result['project']['id'])) { + return $result['project']['id']; + } + return false; + } + + /** + * Create an Issue in Redmine + * @param int $projectId + * @param string $subject + * @param string $description + * @return int|false Returns issue ID on success + */ + public static function createIssue($projectId, $subject, $description, array $extra = [], $switchUser = null) { + // Redmine subject is limited to 255 chars. + $issue = array_merge([ + 'project_id' => $projectId, + 'subject' => mb_substr((string) $subject, 0, 255), + 'description' => $description, + ], $extra); + + $result = self::post('/issues.json', ['issue' => $issue], $switchUser); + // Fallback: if impersonation failed (user not member / inactive), retry as admin. + if (!$result && !empty($switchUser)) { + Toolbox::logInFile('redmine', "Impersonation falhou para '$switchUser' ao criar issue; usando admin.\n"); + $result = self::post('/issues.json', ['issue' => $issue]); + } + if ($result && isset($result['issue']['id'])) { + return $result['issue']['id']; + } + return false; + } + + /** + * Log time to an Issue + * @param int $issueId + * @param float $hours + * @param string $comments + * @return int|false Returns the created time entry ID on success + */ + public static function logTime($issueId, $hours, $comments, $activityId = null, $switchUser = null) { + $entry = [ + 'issue_id' => $issueId, + 'hours' => $hours, + 'comments' => $comments + ]; + if (!empty($activityId)) { + $entry['activity_id'] = (int) $activityId; + } + $payload = ['time_entry' => $entry]; + + $result = self::post('/time_entries.json', $payload, $switchUser); + // Fallback: if impersonation failed, log the time as admin (never lose it). + if (!$result && !empty($switchUser)) { + Toolbox::logInFile('redmine', "Impersonation falhou para '$switchUser' ao lançar tempo; usando admin.\n"); + $result = self::post('/time_entries.json', $payload); + } + if ($result && isset($result['time_entry']['id'])) { + return $result['time_entry']['id']; + } + return false; + } + + /** + * Find a project by identifier + */ + /** + * Get all Redmine projects + * @return array|false + */ + public static function getProjects() { + $result = self::get("/projects.json?limit=100"); + if ($result && isset($result['projects'])) { + return $result['projects']; + } + return false; + } + + /** + * Create a Redmine Project with full custom payload + * @param array $projectData + * @return int|false Returns project ID on success + */ + public static function createProjectFull($projectData) { + $payload = [ + 'project' => $projectData + ]; + + $result = self::post('/projects.json', $payload); + if ($result && isset($result['project']['id'])) { + return $result['project']['id']; + } + return false; + } + /** + * Get all Redmine trackers + * @return array + */ + public static function getTrackers() { + $result = self::get("/trackers.json"); + return ($result && isset($result['trackers'])) ? $result['trackers'] : []; + } + + /** + * Get all Redmine issue statuses + * @return array + */ + public static function getStatuses() { + $result = self::get("/issue_statuses.json"); + return ($result && isset($result['issue_statuses'])) ? $result['issue_statuses'] : []; + } + + /** + * Get all Redmine issue priorities + * @return array + */ + public static function getPriorities() { + $result = self::get("/enumerations/issue_priorities.json"); + return ($result && isset($result['issue_priorities'])) ? $result['issue_priorities'] : []; + } + + /** + * Get all Redmine time entry activities + * @return array + */ + public static function getActivities() { + $result = self::get("/enumerations/time_entry_activities.json"); + return ($result && isset($result['time_entry_activities'])) ? $result['time_entry_activities'] : []; + } + + /** + * Get the issue categories of a given Redmine project (categories are per-project) + * @param int $redmineProjectId + * @return array + */ + public static function getProjectCategories($redmineProjectId) { + $result = self::get("/projects/{$redmineProjectId}/issue_categories.json"); + return ($result && isset($result['issue_categories'])) ? $result['issue_categories'] : []; + } + + /** + * Get all Redmine users (paginated). Requires admin API key. + * @return array + */ + public static function getUsers() { + $users = []; + $offset = 0; + $limit = 100; + do { + $result = self::get("/users.json?limit={$limit}&offset={$offset}"); + if (!$result || !isset($result['users'])) { + break; + } + $users = array_merge($users, $result['users']); + $total = (int) ($result['total_count'] ?? count($users)); + $offset += $limit; + } while ($offset < $total); + return $users; + } + + /** + * Get all Redmine roles. + * @return array + */ + public static function getRoles() { + $result = self::get("/roles.json"); + return ($result && isset($result['roles'])) ? $result['roles'] : []; + } + + /** + * Ensure a Redmine user is a member of a project (idempotent). + * Impersonation requires the user to be a project member with a role. + * @param int $redmineProjectId + * @param int $redmineUserId + * @param int[] $roleIds + * @return bool + */ + public static function ensureProjectMembership($redmineProjectId, $redmineUserId, array $roleIds) { + if (empty($redmineUserId) || empty($roleIds)) { + return false; + } + // Already a member? + $members = self::get("/projects/{$redmineProjectId}/memberships.json?limit=100"); + foreach (($members['memberships'] ?? []) as $m) { + if (isset($m['user']['id']) && (int) $m['user']['id'] === (int) $redmineUserId) { + return true; + } + } + $result = self::post("/projects/{$redmineProjectId}/memberships.json", [ + 'membership' => [ + 'user_id' => (int) $redmineUserId, + 'role_ids' => array_values(array_map('intval', $roleIds)), + ] + ]); + return $result !== false; + } + + /** + * Update an existing issue (used to keep the Redmine status in sync with GLPI). + * @param int $issueId + * @param array $issueData fields to update (e.g. ['status_id' => 3]) + * @return bool + */ + public static function updateIssue($issueId, array $issueData) { + $config = self::getConfig(); + if (!$config || empty($config['redmine_url']) || empty($config['api_key'])) { + return false; + } + $url = rtrim($config['redmine_url'], '/') . "/issues/{$issueId}.json"; + $headers = [ + 'Content-Type: application/json', + 'X-Redmine-API-Key: ' . $config['api_key'] + ]; + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(['issue' => $issueData])); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_exec($ch); + $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + return ($httpcode >= 200 && $httpcode < 300); + } + + /** + * Fetch all metadata used by the config dropdowns and cache it. + * @return array|false The metadata array on success, false if API is unreachable + */ + public static function syncMetadata() { + $projects = self::getProjects(); + if ($projects === false) { + return false; // API unreachable / not configured + } + return [ + 'projects' => $projects, + 'trackers' => self::getTrackers(), + 'statuses' => self::getStatuses(), + 'priorities' => self::getPriorities(), + 'activities' => self::getActivities(), + 'roles' => self::getRoles(), + ]; + } + + /** + * Refresh the local cache of Redmine users (glpi_plugin_redmine_users). + * @return int number of users cached, or -1 on API failure + */ + public static function syncUsers() { + global $DB; + $users = self::getUsers(); + if (empty($users)) { + // Distinguish "no access" from "zero users": getUsers returns [] for both, + // but a working admin key always sees at least itself. Treat [] as failure-safe no-op. + return -1; + } + $DB->delete('glpi_plugin_redmine_users', [1]); + foreach ($users as $u) { + $DB->insert('glpi_plugin_redmine_users', [ + 'redmine_id' => (int) $u['id'], + 'login' => $u['login'] ?? null, + 'mail' => $u['mail'] ?? null, + 'name' => trim(($u['firstname'] ?? '') . ' ' . ($u['lastname'] ?? '')), + ]); + } + return count($users); + } + + /** + * Find a project by identifier + */ + public static function getProjectByIdentifier($identifier) { + $identifier = strtolower(preg_replace('/[^a-zA-Z0-9\-]/', '-', $identifier)); + $result = self::get("/projects/{$identifier}.json"); + if ($result && isset($result['project']['id'])) { + return $result['project']['id']; + } + return false; + } +} diff --git a/logo.png b/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..32e90bddba1622b53cf66557ff2bfd63e81b22a8 GIT binary patch literal 1050 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&DI|LY`7$t6sR6}X7#MzmnTu$sZZAYL$MSD+10qCLPT#1%;YA8_>l|BlOZ!+}oGE(!7rW?&N3_9>jU z^V*wV|2XyY4vIw`s2z6PFYc~5)#mhZ zVF5$Q=aRejYAj!n6eZU2?WRp**`()^eRYAEH|$r6{rh^lbKAdfHadS_JzhES-#3#Z z|GpeO6>)!q-k%qTd#CP8e4Lzn+E=J?F4&A~9pOoL zrtpc>{oDQM0q@=Q`*ffGk89W`wQu{6pZg_QvqSbD`=0#mecM6Ni}hDOe6F|V+m^wY zrE6dRpW)i~$6L4ApYY?Tn=B{vO;75dozg!)t^3PMXXkB`+dO~&{0aLPUkdhrd2-+K zE8gL6Zxudk*tz~k^R3i!yKCp_mf!oddihS_)9-$TK6Jl-z$X5}0h`2&2?ohV4l@ih zEk5|&yrXfd{HuMxRr(jtY4IwP!^E3BSL+|rxHo@w+BKe8k(*!bQJ-V_`o)VEA5Pc5 YED$|yTv~krn9> ['Config'] + ]); + Plugin::registerClass('PluginRedmineProject', [ + 'addtabon' => ['Project'] + ]); + + // Set configuration page + $PLUGIN_HOOKS['config_page']['redmine'] = 'front/config.form.php'; + + // Hook to intercept item creation + $PLUGIN_HOOKS['item_add']['redmine'] = [ + 'TicketTask' => 'plugin_redmine_tickettask_add' + ]; + + // Hook to intercept item updates + $PLUGIN_HOOKS['item_update']['redmine'] = [ + 'TicketTask' => 'plugin_redmine_tickettask_update', + 'Ticket' => 'plugin_redmine_ticket_update' + ]; +} + +/** + * Get the name and the version of the plugin + * + * @return array + */ +function plugin_version_redmine() { + return [ + 'name' => 'Redmine Integration', + 'version' => PLUGIN_REDMINE_VERSION, + 'author' => 'Mindtek', + 'license' => 'GPLv3+', + 'homepage' => '', + 'requirements' => [ + 'glpi' => [ + 'min' => '11.0.0', + 'max' => '11.99.99' + ] + ] + ]; +} + +/** + * Check pre-requisites before install + * + * @return boolean + */ +function plugin_redmine_check_prerequisites() { + if (version_compare(GLPI_VERSION, '11.0.0', 'lt')) { + echo "This plugin requires GLPI >= 11.0.0"; + return false; + } + return true; +} + +/** + * Check configuration process + * + * @param boolean $verbose Whether to display messages. + * + * @return boolean + */ +function plugin_redmine_check_config($verbose = false) { + if ($verbose) { + echo "Installed / not configured"; + } + return true; +} diff --git a/templates/config_form.html.twig b/templates/config_form.html.twig new file mode 100644 index 0000000..038cab6 --- /dev/null +++ b/templates/config_form.html.twig @@ -0,0 +1,135 @@ +{% import 'components/form/fields_macros.html.twig' as fields %} + +
+ {{ fields.csrfField() }} + + + {# --- Conexão --- #} +
+
+

+ {{ __('Conexão com o Redmine', 'redmine') }} +

+ +
+
+
+ {{ fields.urlField('redmine_url', redmine_url, __('URL do Redmine', 'redmine'), { + 'helper': 'https://redmine.exemplo.com.br' + }) }} + {{ fields.passwordField('api_key', '', __('Chave de API (Admin)', 'redmine'), { + 'helper': has_key + ? __('Deixe em branco para manter a chave atual', 'redmine') + : __('Chave de API de um usuário administrador do Redmine', 'redmine') + }) }} +
+
+
+ + {# --- Valores padrão da issue --- #} +
+
+

{{ __('Valores padrão da tarefa (issue) Redmine', 'redmine') }}

+
+
+ {% if not has_metadata %} +
{{ __('Salve a URL e a chave e clique em "Sincronizar Metadados" para carregar as opções.', 'redmine') }}
+ {% endif %} +
+ {{ fields.dropdownArrayField('default_tracker_id', default_tracker_id, tracker_options, __('Tipo (Tracker) padrão', 'redmine'), {'display_emptychoice': true}) }} + {{ fields.dropdownArrayField('default_priority_id', default_priority_id, priority_options, __('Prioridade padrão', 'redmine'), {'display_emptychoice': true}) }} + {{ fields.dropdownArrayField('default_activity_id', default_activity_id, activity_options, __('Atividade padrão (lançamento de tempo)', 'redmine'), {'display_emptychoice': true}) }} +
+
+
+ + {# --- Autores e observadores --- #} +
+
+

{{ __('Autores e observadores', 'redmine') }}

+
+
+
+ {{ __('O autor (técnico atribuído) é adicionado automaticamente como membro do projeto Redmine com o papel abaixo, para permitir a impersonation.', 'redmine') }} +
+
+ {{ fields.dropdownArrayField('default_role_id', default_role_id, role_options, __('Papel padrão para membros (auto-membership)', 'redmine'), {'display_emptychoice': true}) }} + {{ fields.dropdownArrayField('default_watcher_ids', '', redmine_user_options, __('Observadores padrão', 'redmine'), { + 'multiple': true, + 'values': default_watcher_ids + }) }} +
+
+
+ + {# --- Mapa de status --- #} +
+
+

{{ __('Mapa de status: Chamado GLPI → Issue Redmine', 'redmine') }}

+
+
+
+ {% for sid, slabel in glpi_ticket_statuses %} + {{ fields.dropdownArrayField('status_map[' ~ sid ~ ']', attribute(status_map, sid)|default(''), status_options, slabel, {'display_emptychoice': true}) }} + {% endfor %} +
+
+
+ +
+ +
+
+ +{# --- Mapeamento de usuários (overrides) — fora do form principal --- #} +
+
+

{{ __('Mapeamento de usuários (GLPI → Redmine)', 'redmine') }}

+
+
+
+ {{ __('Usuários com o mesmo e-mail nos dois sistemas são associados automaticamente. Use a tabela abaixo apenas para exceções (e-mails diferentes).', 'redmine') }} +
+ + + + + + + + + {% for m in usermap %} + + + + + + {% else %} + + {% endfor %} + +
{{ __('Usuário GLPI', 'redmine') }}{{ __('Usuário Redmine', 'redmine') }}{{ _x('button', 'Delete') }}
{{ m.glpi_name }}{{ m.redmine_name }} +
+ {{ fields.csrfField() }} + + +
+
{{ __('Nenhum override cadastrado.', 'redmine') }}
+ +
+ {{ fields.csrfField() }} + +
+ {{ fields.dropdownField('User', 'override_users_id', 0, __('Usuário GLPI', 'redmine'), {'display_emptychoice': true}) }} + {{ fields.dropdownArrayField('override_redmine_user_id', '', redmine_user_options, __('Usuário Redmine', 'redmine'), {'display_emptychoice': true}) }} +
+
+ +
+
+
+
diff --git a/templates/project_form.html.twig b/templates/project_form.html.twig new file mode 100644 index 0000000..31e483f --- /dev/null +++ b/templates/project_form.html.twig @@ -0,0 +1,73 @@ +{% import 'components/form/fields_macros.html.twig' as fields %} + +
+ + + + + {# Build a {id: name} options map from the cached Redmine projects #} + {% set rp_options = {} %} + {% for rp in redmine_projects %} + {% set rp_options = rp_options|merge({(rp.id): rp.name}) %} + {% endfor %} + +
+
+
Integração Redmine
+ +
+
+ +
+ Selecione um projeto existente para vinculá-lo, ou deixe em ----- e preencha o formulário para criar um novo projeto no Redmine. +
+ +
+ {{ fields.dropdownArrayField('redmine_project_id', '', rp_options, __('Vincular a Projeto Existente', 'redmine'), { + 'display_emptychoice': true, + 'full_width': true + }) }} + + {{ fields.textField('name', project_name, __('Nome', 'redmine'), { + 'full_width': true, + 'required': true + }) }} + + {{ fields.textareaField('description', project_description, __('Descrição', 'redmine'), { + 'full_width': true, + 'rows': 5 + }) }} + + {{ fields.textField('identifier', project_identifier, __('Identificador', 'redmine'), { + 'full_width': true, + 'required': true, + 'helper': __('Deve ter entre 1 e 100 caracteres minúsculos, números ou traços.', 'redmine') + }) }} + + {{ fields.checkboxField('is_public', 0, __('Público', 'redmine'), { + 'full_width': true, + 'helper': __('Projetos públicos são visíveis para todos.', 'redmine') + }) }} + + {{ fields.dropdownArrayField('parent_id', '', rp_options, __('Subprojeto de', 'redmine'), { + 'display_emptychoice': true, + 'full_width': true + }) }} + + {{ fields.dropdownArrayField('enabled_module_names', '', modules, __('Módulos', 'redmine'), { + 'multiple': true, + 'values': ['issue_tracking', 'time_tracking'], + 'full_width': true + }) }} +
+ +
+ +
+
diff --git a/templates/project_linked.html.twig b/templates/project_linked.html.twig new file mode 100644 index 0000000..6d69d84 --- /dev/null +++ b/templates/project_linked.html.twig @@ -0,0 +1,38 @@ +{% import 'components/form/fields_macros.html.twig' as fields %} + +
+ + {{ __('Projeto vinculado à issue Redmine de ID', 'redmine') }} #{{ redmine_project_id }} +
+ +
+ + + + +
+
+
{{ __('Categoria padrão das tarefas deste projeto', 'redmine') }}
+
+
+ {% if cat_options is empty %} +
+ {{ __('Este projeto Redmine não possui categorias cadastradas.', 'redmine') }} +
+ {% else %} +
+ {{ fields.dropdownArrayField('default_category_id', default_category_id, cat_options, __('Categoria padrão', 'redmine'), { + 'display_emptychoice': true + }) }} +
+ {% endif %} +
+ {% if cat_options is not empty %} + + {% endif %} +
+