Redmine Integration 1.3.0

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 <noreply@anthropic.com>
This commit is contained in:
Gemini 2026-06-29 18:32:19 -03:00
commit 80d1576a80
13 changed files with 1601 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
# OS / editores
.DS_Store
*~
*.swp
# Dependências / build (não usadas hoje, mas por garantia)
/vendor/
/node_modules/

54
front/config.form.php Normal file
View file

@ -0,0 +1,54 @@
<?php
include ("../../../inc/includes.php");
Session::checkRight("config", UPDATE);
$config = new PluginRedmineConfig();
global $DB;
if (isset($_POST["update"]) || isset($_POST["sync"])) {
// Save whatever was typed (URL/key/defaults) before anything else.
$config->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();

88
front/project.form.php Normal file
View file

@ -0,0 +1,88 @@
<?php
include ("../../../inc/includes.php");
Session::checkLoginUser();
if (isset($_POST["action"])) {
$action = $_POST["action"];
$projects_id = $_POST["projects_id"];
// Permission check
$project = new Project();
if (!$project->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");

410
hook.php Normal file
View file

@ -0,0 +1,410 @@
<?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);
}
// 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
]);
}
}

206
inc/config.class.php Normal file
View file

@ -0,0 +1,206 @@
<?php
if (!defined('GLPI_ROOT')) {
die("Sorry. You can't access this file directly");
}
class PluginRedmineConfig extends CommonDBTM {
/**
* Define the tab name
* @param int $nb
* @return string
*/
static function getTypeName($nb = 0) {
return 'Redmine Integration';
}
/**
* Encrypt the API key before persisting it.
* An empty key on update means "keep the current one".
* @param array $input
* @return array
*/
function prepareInputForUpdate($input) {
if (array_key_exists('api_key', $input)) {
if ($input['api_key'] === '' || $input['api_key'] === null) {
unset($input['api_key']); // keep the stored key
} else {
$input['api_key'] = (new \GLPIKey())->encrypt((string) $input['api_key']);
}
}
// The status map is posted as status_map[<glpi_status>] = <redmine_status_id>.
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;
}
}

4
inc/event.class.php Normal file
View file

@ -0,0 +1,4 @@
<?php
class PluginRedmineEvent {
// Hooks methods will go here
}

104
inc/project.class.php Normal file
View file

@ -0,0 +1,104 @@
<?php
if (!defined('GLPI_ROOT')) {
die("Sorry. You can't access this file directly");
}
class PluginRedmineProject extends CommonDBTM {
static function getTypeName($nb = 0) {
return 'Redmine';
}
function getTabNameForItem(CommonGLPI $item, $withtemplate = 0) {
if ($item->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()
]);
}
}

400
inc/redmineapi.class.php Normal file
View file

@ -0,0 +1,400 @@
<?php
class PluginRedmineRedmineapi {
/**
* Get Redmine configuration
* @return array|false
*/
private static function getConfig() {
global $DB;
$iterator = $DB->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;
}
}

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

82
setup.php Normal file
View file

@ -0,0 +1,82 @@
<?php
define('PLUGIN_REDMINE_VERSION', '1.3.0');
/**
* Init hooks of the plugin.
*/
function plugin_init_redmine() {
global $PLUGIN_HOOKS;
$PLUGIN_HOOKS['csrf_compliant']['redmine'] = true;
// Register classes
Plugin::registerClass('PluginRedmineConfig', [
'addtabon' => ['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;
}

View file

@ -0,0 +1,135 @@
{% import 'components/form/fields_macros.html.twig' as fields %}
<form method="post" action="{{ action_url }}">
{{ fields.csrfField() }}
<input type="hidden" name="id" value="1">
{# --- Conexão --- #}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="card-title mb-0">
<i class="ti ti-plug-connected me-1"></i>{{ __('Conexão com o Redmine', 'redmine') }}
</h4>
<button type="submit" name="sync" value="1" class="btn btn-sm btn-outline-secondary">
<i class="ti ti-refresh me-1"></i>{{ __('Sincronizar Metadados', 'redmine') }}
</button>
</div>
<div class="card-body">
<div class="row">
{{ 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')
}) }}
</div>
</div>
</div>
{# --- Valores padrão da issue --- #}
<div class="card mb-3">
<div class="card-header">
<h4 class="card-title mb-0"><i class="ti ti-settings me-1"></i>{{ __('Valores padrão da tarefa (issue) Redmine', 'redmine') }}</h4>
</div>
<div class="card-body">
{% if not has_metadata %}
<div class="alert alert-warning">{{ __('Salve a URL e a chave e clique em "Sincronizar Metadados" para carregar as opções.', 'redmine') }}</div>
{% endif %}
<div class="row">
{{ 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}) }}
</div>
</div>
</div>
{# --- Autores e observadores --- #}
<div class="card mb-3">
<div class="card-header">
<h4 class="card-title mb-0"><i class="ti ti-users me-1"></i>{{ __('Autores e observadores', 'redmine') }}</h4>
</div>
<div class="card-body">
<div class="alert alert-info">
{{ __('O autor (técnico atribuído) é adicionado automaticamente como membro do projeto Redmine com o papel abaixo, para permitir a impersonation.', 'redmine') }}
</div>
<div class="row">
{{ 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
}) }}
</div>
</div>
</div>
{# --- Mapa de status --- #}
<div class="card mb-3">
<div class="card-header">
<h4 class="card-title mb-0"><i class="ti ti-arrows-exchange me-1"></i>{{ __('Mapa de status: Chamado GLPI → Issue Redmine', 'redmine') }}</h4>
</div>
<div class="card-body">
<div class="row">
{% for sid, slabel in glpi_ticket_statuses %}
{{ fields.dropdownArrayField('status_map[' ~ sid ~ ']', attribute(status_map, sid)|default(''), status_options, slabel, {'display_emptychoice': true}) }}
{% endfor %}
</div>
</div>
</div>
<div class="d-flex justify-content-end mb-3">
<button type="submit" name="update" value="1" class="btn btn-primary">
<i class="ti ti-device-floppy me-1"></i>{{ _x('button', 'Save') }}
</button>
</div>
</form>
{# --- Mapeamento de usuários (overrides) — fora do form principal --- #}
<div class="card mb-3">
<div class="card-header">
<h4 class="card-title mb-0"><i class="ti ti-user-cog me-1"></i>{{ __('Mapeamento de usuários (GLPI → Redmine)', 'redmine') }}</h4>
</div>
<div class="card-body">
<div class="alert alert-info">
{{ __('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') }}
</div>
<table class="table table-sm align-middle">
<thead><tr>
<th>{{ __('Usuário GLPI', 'redmine') }}</th>
<th>{{ __('Usuário Redmine', 'redmine') }}</th>
<th class="text-end">{{ _x('button', 'Delete') }}</th>
</tr></thead>
<tbody>
{% for m in usermap %}
<tr>
<td>{{ m.glpi_name }}</td>
<td>{{ m.redmine_name }}</td>
<td class="text-end">
<form method="post" action="{{ action_url }}" class="d-inline">
{{ fields.csrfField() }}
<input type="hidden" name="del_usermap" value="{{ m.id }}">
<button type="submit" class="btn btn-sm btn-outline-danger"><i class="ti ti-trash"></i></button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="3" class="text-muted">{{ __('Nenhum override cadastrado.', 'redmine') }}</td></tr>
{% endfor %}
</tbody>
</table>
<form method="post" action="{{ action_url }}" class="mt-3">
{{ fields.csrfField() }}
<input type="hidden" name="add_usermap" value="1">
<div class="row">
{{ 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}) }}
</div>
<div class="text-end">
<button type="submit" class="btn btn-outline-primary"><i class="ti ti-plus me-1"></i>{{ _x('button', 'Add') }}</button>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,73 @@
{% import 'components/form/fields_macros.html.twig' as fields %}
<form action="{{ action_url }}" method="post" class="mt-4" data-ajax="false">
<input type="hidden" name="projects_id" value="{{ projects_id }}">
<input type="hidden" name="_glpi_csrf_token" value="{{ csrf_token_value }}">
<input type="hidden" name="_glpi_simple_form" value="1">
{# 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 %}
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-sitemap"></i> Integração Redmine</h5>
<button type="submit" name="action" value="sync" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-sync"></i> Sincronizar Metadados
</button>
</div>
<div class="card-body">
<div class="alert alert-info">
Selecione um projeto existente para vinculá-lo, <strong>ou</strong> deixe em <strong>-----</strong> e preencha o formulário para criar um novo projeto no Redmine.
</div>
<div class="row">
{{ 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
}) }}
</div>
</div>
<div class="card-footer text-center">
<button type="submit" name="action" value="process" class="btn btn-primary px-5">
<i class="fas fa-save"></i> Criar ou Vincular
</button>
</div>
</div>
</form>

View file

@ -0,0 +1,38 @@
{% import 'components/form/fields_macros.html.twig' as fields %}
<div class="alert alert-success mt-3 d-flex align-items-center">
<i class="ti ti-link me-2"></i>
{{ __('Projeto vinculado à issue Redmine de ID', 'redmine') }} <strong class="ms-1">#{{ redmine_project_id }}</strong>
</div>
<form method="post" action="{{ action_url }}" class="mt-2">
<input type="hidden" name="_glpi_csrf_token" value="{{ csrf_token_value }}">
<input type="hidden" name="projects_id" value="{{ projects_id }}">
<input type="hidden" name="action" value="set_category">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="ti ti-category me-1"></i>{{ __('Categoria padrão das tarefas deste projeto', 'redmine') }}</h5>
</div>
<div class="card-body">
{% if cat_options is empty %}
<div class="alert alert-secondary mb-0">
{{ __('Este projeto Redmine não possui categorias cadastradas.', 'redmine') }}
</div>
{% else %}
<div class="row">
{{ fields.dropdownArrayField('default_category_id', default_category_id, cat_options, __('Categoria padrão', 'redmine'), {
'display_emptychoice': true
}) }}
</div>
{% endif %}
</div>
{% if cat_options is not empty %}
<div class="card-footer text-end">
<button type="submit" class="btn btn-primary">
<i class="ti ti-device-floppy me-1"></i>{{ _x('button', 'Save') }}
</button>
</div>
{% endif %}
</div>
</form>