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:
commit
80d1576a80
13 changed files with 1601 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal 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
54
front/config.form.php
Normal 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
88
front/project.form.php
Normal 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
410
hook.php
Normal 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
206
inc/config.class.php
Normal 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
4
inc/event.class.php
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?php
|
||||
class PluginRedmineEvent {
|
||||
// Hooks methods will go here
|
||||
}
|
||||
104
inc/project.class.php
Normal file
104
inc/project.class.php
Normal 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
400
inc/redmineapi.class.php
Normal 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
BIN
logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1 KiB |
82
setup.php
Normal file
82
setup.php
Normal 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;
|
||||
}
|
||||
135
templates/config_form.html.twig
Normal file
135
templates/config_form.html.twig
Normal 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>
|
||||
73
templates/project_form.html.twig
Normal file
73
templates/project_form.html.twig
Normal 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>
|
||||
38
templates/project_linked.html.twig
Normal file
38
templates/project_linked.html.twig
Normal 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>
|
||||
Loading…
Reference in a new issue