feat(2.0): Fase 1 — motor de templates de payload (design + engine + fallback 1.x)
- docs/DESIGN-2.0.md: decisões fechadas (tags PT, editor linha-a-linha,
escopo global, dry-run com preview) e arquitetura Motor x Template
- PluginRedmineTemplateengine: render mustache-like ({{ var | filtro }}),
regra de tipos (tag inteira = tipo nativo), lookup indexado
(map.status[chamado.status]), filtros (truncar, sem_html, minusculo,
identificador, padrao), prune de nulls, seeds 1.x-equivalentes e
descoberta de custom fields obrigatórios
- tabela glpi_plugin_redmine_templates (operation único, is_active)
- wiring com precedência de template + fallback no render em
create_issue, log_time e update_status
- validado: 12/12 unit + E2E (template ativo cria issue/tempo; template
quebrado cai no motor 1.x sem perder lançamento)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b8f89cda8d
commit
7ac1f8ac44
6 changed files with 574 additions and 46 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -4,6 +4,16 @@ Todas as mudanças relevantes deste plugin são documentadas aqui.
|
||||||
O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/1.0.0/)
|
O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/1.0.0/)
|
||||||
e o versionamento segue [SemVer](https://semver.org/lang/pt-BR/).
|
e o versionamento segue [SemVer](https://semver.org/lang/pt-BR/).
|
||||||
|
|
||||||
|
## [2.0.0-dev] — em desenvolvimento (branch main; estável = stable/1.x)
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
- **Motor de templates de payload** (Fase 1, invisível na UI — ver `docs/DESIGN-2.0.md`):
|
||||||
|
templates JSON com tags `{{ var | filtro }}` por operação (`create_issue`, `log_time`,
|
||||||
|
`update_status`, `create_project`), tabela `glpi_plugin_redmine_templates`, seeds
|
||||||
|
equivalentes ao comportamento 1.x e descoberta de custom fields obrigatórios.
|
||||||
|
Sem template ativo, o plugin se comporta exatamente como o 1.x; erro de render
|
||||||
|
também cai no 1.x (nunca perde lançamento).
|
||||||
|
|
||||||
## [1.6.0]
|
## [1.6.0]
|
||||||
|
|
||||||
### Adicionado
|
### Adicionado
|
||||||
|
|
|
||||||
100
docs/DESIGN-2.0.md
Normal file
100
docs/DESIGN-2.0.md
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
# DESIGN 2.0 — Templates de Payload (motor de mapeamento dinâmico)
|
||||||
|
|
||||||
|
> Objetivo: transformar o plugin — hoje moldado ao ambiente Mindtek — em **produto**:
|
||||||
|
> os payloads enviados ao Redmine passam a ser **templates editáveis** pelo admin,
|
||||||
|
> com variáveis do GLPI e campos descobertos do Redmine de destino.
|
||||||
|
> Linha estável (clientes atuais): branch `stable/1.x`. Evolução: `main` (2.0).
|
||||||
|
|
||||||
|
## Decisões fechadas (2026-07-02)
|
||||||
|
|
||||||
|
| Decisão | Escolha |
|
||||||
|
|---|---|
|
||||||
|
| Idioma das tags | **PT-BR** (`{{ chamado.titulo }}`) — público Mindplace |
|
||||||
|
| UX do editor | **Linha-a-linha** (campo → valor/tag → obrigatório) com toggle "ver JSON" |
|
||||||
|
| Escopo do template | **Global por operação** na 2.0; override por vínculo na 2.1 |
|
||||||
|
| Dry-run | **Preview de render sempre**; teste real opt-in contra alvo escolhido |
|
||||||
|
| Sintaxe | Mustache-like (`{{ var \| filtro }}`), **sem Twig completo** (segurança) |
|
||||||
|
|
||||||
|
## Fronteira Motor × Template
|
||||||
|
|
||||||
|
**Motor (fixo, não editável):** auth, impersonation + auto-membership + fallback admin,
|
||||||
|
idempotência (1 issue/chamado, tempo 1×/task), recuperação de issue órfã, gates
|
||||||
|
(Feito + duração>0), normalização de identifier, logs, self-healing de usuários.
|
||||||
|
|
||||||
|
**Template (editável):** o corpo JSON de cada operação — quais campos vão e com
|
||||||
|
quais valores (tags, valores fixos, custom fields do ambiente).
|
||||||
|
|
||||||
|
O admin mapeia **dados**, nunca **lógica**.
|
||||||
|
|
||||||
|
## Operações e seeds
|
||||||
|
|
||||||
|
4 operações fixas: `create_project`, `create_issue`, `log_time`, `update_status`.
|
||||||
|
O botão **"Gerar template"** produz o seed = comportamento atual do motor 1.x
|
||||||
|
+ custom fields obrigatórios descobertos via `/custom_fields.json`
|
||||||
|
(`customized_type` project/issue/time_entry conforme a operação).
|
||||||
|
|
||||||
|
Seed `create_issue` (exemplo):
|
||||||
|
```json
|
||||||
|
{ "issue": {
|
||||||
|
"project_id": "{{ vinculo.projeto_redmine }}",
|
||||||
|
"subject": "{{ chamado.titulo | truncar:255 }}",
|
||||||
|
"description": "{{ chamado.descricao | sem_html }}",
|
||||||
|
"tracker_id": "{{ config.tracker_padrao }}",
|
||||||
|
"priority_id": "{{ config.prioridade_padrao }}",
|
||||||
|
"status_id": "{{ map.status[chamado.status] }}",
|
||||||
|
"category_id": "{{ vinculo.categoria_padrao }}",
|
||||||
|
"watcher_user_ids": "{{ config.observadores }}"
|
||||||
|
} }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sintaxe das tags
|
||||||
|
|
||||||
|
- `{{ namespace.campo }}` — resolução por caminho no contexto.
|
||||||
|
- `{{ mapa[chave.dinamica] }}` — lookup indexado (ex.: `map.status[chamado.status]`).
|
||||||
|
- Filtros em pipeline: `{{ var | filtro:arg | filtro2 }}`.
|
||||||
|
- `truncar:N`, `sem_html`, `minusculo`, `identificador`, `padrao:'texto fixo'`.
|
||||||
|
|
||||||
|
**Regra de tipos:** tag ocupando o **valor inteiro** da string → rende com o **tipo
|
||||||
|
nativo** da variável (número/array/bool, sem aspas no JSON final). Tag **embutida**
|
||||||
|
em texto → interpolação de string. Valores `null`/arrays vazios pós-render são
|
||||||
|
**removidos** do payload (igual ao motor 1.x, que só inclui campos definidos).
|
||||||
|
|
||||||
|
## Contexto (paleta de variáveis)
|
||||||
|
|
||||||
|
| Namespace | Campos | Origem |
|
||||||
|
|---|---|---|
|
||||||
|
| `chamado.*` | id, titulo, descricao, status, entidade | Ticket |
|
||||||
|
| `tarefa.*` | id, horas, segundos, descricao, comentario*, data | TicketTask |
|
||||||
|
| `vinculo.*` | projeto_redmine, issue_redmine, categoria_padrao | tabelas do plugin |
|
||||||
|
| `config.*` | tracker_padrao, prioridade_padrao, atividade_padrao, observadores | formconfig |
|
||||||
|
| `map.status` | mapa status GLPI→Redmine | formconfig |
|
||||||
|
| `form.*` | campos do form de criar projeto (inclui `cf_<id>`) | aba Redmine |
|
||||||
|
|
||||||
|
\* `tarefa.comentario` = descrição sem HTML **ou** o fallback "Tempo lançado via
|
||||||
|
GLPI (chamado #N)" — o fallback é responsabilidade do motor (contexto), não do template.
|
||||||
|
|
||||||
|
## Armazenamento e retrocompatibilidade
|
||||||
|
|
||||||
|
Tabela `glpi_plugin_redmine_templates` (`operation` único, `template` MEDIUMTEXT,
|
||||||
|
`is_active`). **Sem template ativo → motor 1.x intocado.** Template ativo com erro
|
||||||
|
de **render** → log + motor 1.x (nunca perde lançamento). Template ativo válido →
|
||||||
|
payload do template é o enviado (sem retry pela via legada em erro de API — respeita
|
||||||
|
a escolha do admin e evita duplicação).
|
||||||
|
|
||||||
|
## Fases
|
||||||
|
|
||||||
|
1. **Motor (esta fase):** engine de render + tabela + seeds + wiring com fallback nas
|
||||||
|
operações do hook (`create_issue`, `log_time`, `update_status`). Invisível na UI.
|
||||||
|
2. **UI:** editor linha-a-linha + paleta + "Gerar template" + wiring do `create_project`
|
||||||
|
(contexto `form.*`).
|
||||||
|
3. **Validação:** preview de render + dry-run opcional com tradução de 422.
|
||||||
|
4. **2.1:** override por vínculo, custom fields de issue/time_entry na paleta,
|
||||||
|
perfis múltiplos (um GLPI → vários Redmines).
|
||||||
|
|
||||||
|
## Referências
|
||||||
|
|
||||||
|
- Caso de estudo: ambiente Mindtek (KB-PLUGIN-037) — nativo vs "o que o Redmine exigiu"
|
||||||
|
(custom fields 4/5 de projeto, activity/comments obrigatórios no time entry, trackers
|
||||||
|
por projeto).
|
||||||
|
- Redmine **não tem** introspecção de schema: descoberta = esqueleto fixo por operação
|
||||||
|
+ `/custom_fields.json` + dry-run com tradução de erros 422.
|
||||||
89
hook.php
89
hook.php
|
|
@ -105,6 +105,20 @@ function plugin_redmine_install() {
|
||||||
$migration->addPostQuery($query);
|
$migration->addPostQuery($query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Payload templates (motor 2.0 — ver docs/DESIGN-2.0.md). Sem template
|
||||||
|
// ativo, o plugin usa o comportamento 1.x.
|
||||||
|
if (!$DB->tableExists("glpi_plugin_redmine_templates")) {
|
||||||
|
$query = "CREATE TABLE `glpi_plugin_redmine_templates` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`operation` varchar(50) NOT NULL,
|
||||||
|
`template` MEDIUMTEXT DEFAULT NULL,
|
||||||
|
`is_active` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `operation` (`operation`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
|
||||||
|
$migration->addPostQuery($query);
|
||||||
|
}
|
||||||
|
|
||||||
// Cache of Redmine users (populated by "Sincronizar Metadados").
|
// Cache of Redmine users (populated by "Sincronizar Metadados").
|
||||||
if (!$DB->tableExists("glpi_plugin_redmine_users")) {
|
if (!$DB->tableExists("glpi_plugin_redmine_users")) {
|
||||||
$query = "CREATE TABLE `glpi_plugin_redmine_users` (
|
$query = "CREATE TABLE `glpi_plugin_redmine_users` (
|
||||||
|
|
@ -173,7 +187,8 @@ function plugin_redmine_uninstall() {
|
||||||
"glpi_plugin_redmine_tasks",
|
"glpi_plugin_redmine_tasks",
|
||||||
"glpi_plugin_redmine_users",
|
"glpi_plugin_redmine_users",
|
||||||
"glpi_plugin_redmine_usermap",
|
"glpi_plugin_redmine_usermap",
|
||||||
"glpi_plugin_redmine_entities"
|
"glpi_plugin_redmine_entities",
|
||||||
|
"glpi_plugin_redmine_templates"
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($tables as $table) {
|
foreach ($tables as $table) {
|
||||||
|
|
@ -216,6 +231,21 @@ function plugin_redmine_ticket_update(Ticket $ticket) {
|
||||||
}
|
}
|
||||||
$issueId = (int) $issueMap->current()['redmine_issue_id'];
|
$issueId = (int) $issueMap->current()['redmine_issue_id'];
|
||||||
|
|
||||||
|
// Motor 2.0: template ativo tem precedência; erro de render cai no 1.x.
|
||||||
|
$tpl = PluginRedmineTemplateengine::getActive('update_status');
|
||||||
|
if ($tpl) {
|
||||||
|
try {
|
||||||
|
$ctx = PluginRedmineTemplateengine::buildContext($ticket, null, null, null, $issueId);
|
||||||
|
$payload = PluginRedmineTemplateengine::render($tpl['template'], $ctx);
|
||||||
|
if (!empty($payload['issue'])) {
|
||||||
|
PluginRedmineRedmineapi::updateIssue($issueId, $payload['issue']);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Toolbox::logInFile('redmine', "Template update_status falhou no render (" . $e->getMessage() . ") — usando motor 1.x.\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$mappedStatus = PluginRedmineConfig::mapTicketStatus((int) $ticket->fields['status']);
|
$mappedStatus = PluginRedmineConfig::mapTicketStatus((int) $ticket->fields['status']);
|
||||||
if ($mappedStatus) {
|
if ($mappedStatus) {
|
||||||
PluginRedmineRedmineapi::updateIssue($issueId, ['status_id' => $mappedStatus]);
|
PluginRedmineRedmineapi::updateIssue($issueId, ['status_id' => $mappedStatus]);
|
||||||
|
|
@ -380,7 +410,28 @@ function _plugin_redmine_process_tickettask(TicketTask $task) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$issueId) {
|
if (!$issueId) {
|
||||||
// Build the issue from the ticket + the configured defaults.
|
// Author = the GLPI assigned technician, via impersonation.
|
||||||
|
$techId = _plugin_redmine_assigned_tech($ticketId);
|
||||||
|
$switchUser = _plugin_redmine_impersonation_login($techId, $redmineProjectId);
|
||||||
|
|
||||||
|
// Motor 2.0: template ativo tem precedência. Erro de RENDER cai no 1.x;
|
||||||
|
// template válido é a palavra final (sem retry pela via legada).
|
||||||
|
$tpl = PluginRedmineTemplateengine::getActive('create_issue');
|
||||||
|
if ($tpl) {
|
||||||
|
try {
|
||||||
|
$ctx = PluginRedmineTemplateengine::buildContext($ticket, $task, $linkRow, $redmineProjectId);
|
||||||
|
$payload = PluginRedmineTemplateengine::render($tpl['template'], $ctx);
|
||||||
|
if (!empty($payload['issue'])) {
|
||||||
|
$issueId = PluginRedmineRedmineapi::createIssueFromPayload($payload['issue'], $switchUser);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Toolbox::logInFile('redmine', "Template create_issue falhou no render (" . $e->getMessage() . ") — usando motor 1.x.\n");
|
||||||
|
$tpl = null; // render quebrado: deixa o 1.x assumir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$tpl) {
|
||||||
|
// Motor 1.x: issue a partir do chamado + defaults configurados.
|
||||||
$cfg = PluginRedmineConfig::getConfigRow();
|
$cfg = PluginRedmineConfig::getConfigRow();
|
||||||
$extra = [];
|
$extra = [];
|
||||||
if (!empty($cfg['default_tracker_id'])) { $extra['tracker_id'] = (int) $cfg['default_tracker_id']; }
|
if (!empty($cfg['default_tracker_id'])) { $extra['tracker_id'] = (int) $cfg['default_tracker_id']; }
|
||||||
|
|
@ -399,10 +450,6 @@ function _plugin_redmine_process_tickettask(TicketTask $task) {
|
||||||
$watchers = PluginRedmineConfig::getDefaultWatcherIds();
|
$watchers = PluginRedmineConfig::getDefaultWatcherIds();
|
||||||
if (!empty($watchers)) { $extra['watcher_user_ids'] = $watchers; }
|
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.
|
// Subject = ticket title; description stripped of HTML.
|
||||||
$description = strip_tags(html_entity_decode((string) $ticket->fields['content']));
|
$description = strip_tags(html_entity_decode((string) $ticket->fields['content']));
|
||||||
$issueId = PluginRedmineRedmineapi::createIssue(
|
$issueId = PluginRedmineRedmineapi::createIssue(
|
||||||
|
|
@ -412,6 +459,8 @@ function _plugin_redmine_process_tickettask(TicketTask $task) {
|
||||||
$extra,
|
$extra,
|
||||||
$switchUser
|
$switchUser
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ($issueId) {
|
if ($issueId) {
|
||||||
$DB->insert('glpi_plugin_redmine_tickets', [
|
$DB->insert('glpi_plugin_redmine_tickets', [
|
||||||
'tickets_id' => $ticketId,
|
'tickets_id' => $ticketId,
|
||||||
|
|
@ -425,6 +474,29 @@ function _plugin_redmine_process_tickettask(TicketTask $task) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 = null;
|
||||||
|
|
||||||
|
// Motor 2.0: template ativo tem precedência; erro de render cai no 1.x.
|
||||||
|
$tpl = PluginRedmineTemplateengine::getActive('log_time');
|
||||||
|
if ($tpl) {
|
||||||
|
try {
|
||||||
|
$ctx = PluginRedmineTemplateengine::buildContext($ticket, $task, $linkRow, $redmineProjectId, $issueId);
|
||||||
|
$payload = PluginRedmineTemplateengine::render($tpl['template'], $ctx);
|
||||||
|
if (!empty($payload['time_entry'])) {
|
||||||
|
$timeEntryId = PluginRedmineRedmineapi::logTimeFromPayload($payload['time_entry'], $timeSwitchUser);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Toolbox::logInFile('redmine', "Template log_time falhou no render (" . $e->getMessage() . ") — usando motor 1.x.\n");
|
||||||
|
$tpl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$tpl) {
|
||||||
|
// Motor 1.x
|
||||||
$cfg = PluginRedmineConfig::getConfigRow();
|
$cfg = PluginRedmineConfig::getConfigRow();
|
||||||
$hours = round($task->fields['actiontime'] / HOUR_TIMESTAMP, 2);
|
$hours = round($task->fields['actiontime'] / HOUR_TIMESTAMP, 2);
|
||||||
|
|
||||||
|
|
@ -434,10 +506,6 @@ function _plugin_redmine_process_tickettask(TicketTask $task) {
|
||||||
$comments = "Tempo lançado via GLPI (chamado #{$ticketId})";
|
$comments = "Tempo lançado via GLPI (chamado #{$ticketId})";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Time entry author = the GLPI user who logged the task, via impersonation.
|
|
||||||
$taskUserId = (int) ($task->fields['users_id'] ?? 0);
|
|
||||||
$timeSwitchUser = _plugin_redmine_impersonation_login($taskUserId, $redmineProjectId);
|
|
||||||
|
|
||||||
$timeEntryId = PluginRedmineRedmineapi::logTime(
|
$timeEntryId = PluginRedmineRedmineapi::logTime(
|
||||||
$issueId,
|
$issueId,
|
||||||
$hours,
|
$hours,
|
||||||
|
|
@ -445,6 +513,7 @@ function _plugin_redmine_process_tickettask(TicketTask $task) {
|
||||||
!empty($cfg['default_activity_id']) ? (int) $cfg['default_activity_id'] : null,
|
!empty($cfg['default_activity_id']) ? (int) $cfg['default_activity_id'] : null,
|
||||||
$timeSwitchUser
|
$timeSwitchUser
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ($timeEntryId) {
|
if ($timeEntryId) {
|
||||||
$DB->insert('glpi_plugin_redmine_tasks', [
|
$DB->insert('glpi_plugin_redmine_tasks', [
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,34 @@ class PluginRedmineRedmineapi {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an Issue from a fully-built payload (template engine 2.0).
|
||||||
|
* @param array $issue corpo do objeto issue já renderizado
|
||||||
|
* @return int|false
|
||||||
|
*/
|
||||||
|
public static function createIssueFromPayload(array $issue, $switchUser = null) {
|
||||||
|
$result = self::post('/issues.json', ['issue' => $issue], $switchUser);
|
||||||
|
if (!$result && !empty($switchUser)) {
|
||||||
|
Toolbox::logInFile('redmine', "Impersonation falhou para '$switchUser' ao criar issue (template); usando admin.\n");
|
||||||
|
$result = self::post('/issues.json', ['issue' => $issue]);
|
||||||
|
}
|
||||||
|
return ($result && isset($result['issue']['id'])) ? $result['issue']['id'] : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log time from a fully-built payload (template engine 2.0).
|
||||||
|
* @param array $entry corpo do time_entry já renderizado
|
||||||
|
* @return int|false
|
||||||
|
*/
|
||||||
|
public static function logTimeFromPayload(array $entry, $switchUser = null) {
|
||||||
|
$result = self::post('/time_entries.json', ['time_entry' => $entry], $switchUser);
|
||||||
|
if (!$result && !empty($switchUser)) {
|
||||||
|
Toolbox::logInFile('redmine', "Impersonation falhou para '$switchUser' ao lançar tempo (template); usando admin.\n");
|
||||||
|
$result = self::post('/time_entries.json', ['time_entry' => $entry]);
|
||||||
|
}
|
||||||
|
return ($result && isset($result['time_entry']['id'])) ? $result['time_entry']['id'] : false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log time to an Issue
|
* Log time to an Issue
|
||||||
* @param int $issueId
|
* @param int $issueId
|
||||||
|
|
|
||||||
321
inc/templateengine.class.php
Normal file
321
inc/templateengine.class.php
Normal file
|
|
@ -0,0 +1,321 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
if (!defined('GLPI_ROOT')) {
|
||||||
|
die("Sorry. You can't access this file directly");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Motor de templates de payload (2.0) — ver docs/DESIGN-2.0.md.
|
||||||
|
*
|
||||||
|
* Renderiza templates JSON com tags mustache-like ({{ var | filtro }}) sobre um
|
||||||
|
* contexto montado pelo motor. Sem template ativo, o plugin usa o motor 1.x.
|
||||||
|
*/
|
||||||
|
class PluginRedmineTemplateengine {
|
||||||
|
|
||||||
|
const OPERATIONS = ['create_project', 'create_issue', 'log_time', 'update_status'];
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Armazenamento
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template ativo para a operação, ou null (=> motor 1.x).
|
||||||
|
* @return array|null linha da tabela
|
||||||
|
*/
|
||||||
|
public static function getActive($operation) {
|
||||||
|
global $DB;
|
||||||
|
if (!$DB->tableExists('glpi_plugin_redmine_templates')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$it = $DB->request([
|
||||||
|
'FROM' => 'glpi_plugin_redmine_templates',
|
||||||
|
'WHERE' => ['operation' => $operation, 'is_active' => 1]
|
||||||
|
]);
|
||||||
|
return count($it) > 0 ? $it->current() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grava (upsert) o template de uma operação.
|
||||||
|
*/
|
||||||
|
public static function save($operation, $templateJson, $active = false) {
|
||||||
|
global $DB;
|
||||||
|
$row = [
|
||||||
|
'template' => $templateJson,
|
||||||
|
'is_active' => $active ? 1 : 0,
|
||||||
|
];
|
||||||
|
$it = $DB->request(['FROM' => 'glpi_plugin_redmine_templates', 'WHERE' => ['operation' => $operation]]);
|
||||||
|
if (count($it) > 0) {
|
||||||
|
$DB->update('glpi_plugin_redmine_templates', $row, ['operation' => $operation]);
|
||||||
|
} else {
|
||||||
|
$DB->insert('glpi_plugin_redmine_templates', $row + ['operation' => $operation]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Seeds (comportamento 1.x expresso como template)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template padrão da operação (seed do botão "Gerar template").
|
||||||
|
* @return array estrutura do payload (antes do json_encode)
|
||||||
|
*/
|
||||||
|
public static function defaultTemplate($operation) {
|
||||||
|
switch ($operation) {
|
||||||
|
case 'create_issue':
|
||||||
|
return ['issue' => [
|
||||||
|
'project_id' => '{{ vinculo.projeto_redmine }}',
|
||||||
|
'subject' => '{{ chamado.titulo | truncar:255 }}',
|
||||||
|
'description' => '{{ chamado.descricao | sem_html }}',
|
||||||
|
'tracker_id' => '{{ config.tracker_padrao }}',
|
||||||
|
'priority_id' => '{{ config.prioridade_padrao }}',
|
||||||
|
'status_id' => '{{ map.status[chamado.status] }}',
|
||||||
|
'category_id' => '{{ vinculo.categoria_padrao }}',
|
||||||
|
'watcher_user_ids' => '{{ config.observadores }}',
|
||||||
|
]];
|
||||||
|
case 'log_time':
|
||||||
|
return ['time_entry' => [
|
||||||
|
'issue_id' => '{{ vinculo.issue_redmine }}',
|
||||||
|
'hours' => '{{ tarefa.horas }}',
|
||||||
|
'comments' => '{{ tarefa.comentario }}',
|
||||||
|
'activity_id' => '{{ config.atividade_padrao }}',
|
||||||
|
]];
|
||||||
|
case 'update_status':
|
||||||
|
return ['issue' => [
|
||||||
|
'status_id' => '{{ map.status[chamado.status] }}',
|
||||||
|
]];
|
||||||
|
case 'create_project':
|
||||||
|
return ['project' => [
|
||||||
|
'name' => '{{ form.nome }}',
|
||||||
|
'identifier' => '{{ form.identificador | identificador }}',
|
||||||
|
'description' => '{{ form.descricao }}',
|
||||||
|
'is_public' => '{{ form.publico }}',
|
||||||
|
'parent_id' => '{{ form.subprojeto_de }}',
|
||||||
|
'tracker_ids' => '{{ form.trackers }}',
|
||||||
|
'enabled_module_names' => '{{ form.modulos }}',
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enriquece um seed com os custom fields OBRIGATÓRIOS descobertos no cache
|
||||||
|
* de metadados (por tipo de objeto Redmine da operação).
|
||||||
|
*/
|
||||||
|
public static function mergeDiscoveredCustomFields($operation, array $template) {
|
||||||
|
$type_by_op = [
|
||||||
|
'create_project' => 'project',
|
||||||
|
'create_issue' => 'issue',
|
||||||
|
'log_time' => 'time_entry',
|
||||||
|
];
|
||||||
|
if (!isset($type_by_op[$operation])) {
|
||||||
|
return $template;
|
||||||
|
}
|
||||||
|
global $DB;
|
||||||
|
$it = $DB->request(['FROM' => 'glpi_plugin_redmine_configs', 'WHERE' => ['id' => 1]]);
|
||||||
|
$config = count($it) > 0 ? $it->current() : [];
|
||||||
|
$metadata = !empty($config['metadata_cache']) ? json_decode($config['metadata_cache'], true) : [];
|
||||||
|
|
||||||
|
$root = array_key_first($template);
|
||||||
|
foreach (($metadata['project_custom_fields'] ?? []) as $f) {
|
||||||
|
// metadata_cache hoje guarda só os de projeto; issue/time_entry na 2.1
|
||||||
|
if (($f['customized_type'] ?? 'project') !== $type_by_op[$operation]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!empty($f['is_required'])) {
|
||||||
|
$template[$root]['custom_field_values'][(string) $f['id']] =
|
||||||
|
($operation === 'create_project') ? '{{ form.cf_' . (int) $f['id'] . ' }}' : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $template;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Contexto
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monta o contexto de variáveis (a "paleta") para o render.
|
||||||
|
*/
|
||||||
|
public static function buildContext(?Ticket $ticket = null, ?TicketTask $task = null,
|
||||||
|
?array $linkRow = null, $redmineProjectId = null, $issueId = null) {
|
||||||
|
$cfg = PluginRedmineConfig::getConfigRow();
|
||||||
|
$statusMap = !empty($cfg['status_map']) ? (json_decode($cfg['status_map'], true) ?: []) : [];
|
||||||
|
|
||||||
|
$ctx = [
|
||||||
|
'config' => [
|
||||||
|
'tracker_padrao' => ((int) ($cfg['default_tracker_id'] ?? 0)) ?: null,
|
||||||
|
'prioridade_padrao' => ((int) ($cfg['default_priority_id'] ?? 0)) ?: null,
|
||||||
|
'atividade_padrao' => ((int) ($cfg['default_activity_id'] ?? 0)) ?: null,
|
||||||
|
'observadores' => PluginRedmineConfig::getDefaultWatcherIds() ?: null,
|
||||||
|
],
|
||||||
|
'map' => [
|
||||||
|
// ids como int para o JSON final sair numérico
|
||||||
|
'status' => array_map('intval', $statusMap),
|
||||||
|
],
|
||||||
|
'vinculo' => [
|
||||||
|
'projeto_redmine' => $redmineProjectId ? (int) $redmineProjectId : null,
|
||||||
|
'issue_redmine' => $issueId ? (int) $issueId : null,
|
||||||
|
'categoria_padrao' => ($linkRow && !empty($linkRow['default_category_id']))
|
||||||
|
? (int) $linkRow['default_category_id'] : null,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($ticket !== null) {
|
||||||
|
$ctx['chamado'] = [
|
||||||
|
'id' => (int) $ticket->getID(),
|
||||||
|
'titulo' => (string) $ticket->fields['name'],
|
||||||
|
'descricao' => (string) $ticket->fields['content'],
|
||||||
|
'status' => (int) $ticket->fields['status'],
|
||||||
|
'entidade' => (int) $ticket->fields['entities_id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($task !== null) {
|
||||||
|
$desc = trim(strip_tags(html_entity_decode((string) ($task->fields['content'] ?? ''))));
|
||||||
|
$ctx['tarefa'] = [
|
||||||
|
'id' => (int) $task->getID(),
|
||||||
|
'segundos' => (int) $task->fields['actiontime'],
|
||||||
|
'horas' => round($task->fields['actiontime'] / HOUR_TIMESTAMP, 2),
|
||||||
|
'descricao' => $desc,
|
||||||
|
// fallback do comentário obrigatório é responsabilidade do MOTOR
|
||||||
|
'comentario' => ($desc !== '') ? $desc
|
||||||
|
: 'Tempo lançado via GLPI (chamado #' . (int) $task->fields['tickets_id'] . ')',
|
||||||
|
'data' => substr((string) ($task->fields['date'] ?? ''), 0, 10) ?: null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Render
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renderiza um template JSON com o contexto. Lança RuntimeException em
|
||||||
|
* template inválido (o chamador cai no motor 1.x).
|
||||||
|
* @return array payload pronto (nulls e arrays vazios removidos)
|
||||||
|
*/
|
||||||
|
public static function render($templateJson, array $ctx) {
|
||||||
|
$tpl = json_decode((string) $templateJson, true);
|
||||||
|
if (!is_array($tpl)) {
|
||||||
|
throw new \RuntimeException('template não é JSON válido');
|
||||||
|
}
|
||||||
|
$out = self::walk($tpl, $ctx);
|
||||||
|
return self::prune($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function walk($node, array $ctx) {
|
||||||
|
if (is_array($node)) {
|
||||||
|
$out = [];
|
||||||
|
foreach ($node as $k => $v) {
|
||||||
|
$out[$k] = self::walk($v, $ctx);
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
if (is_string($node)) {
|
||||||
|
return self::renderString($node, $ctx);
|
||||||
|
}
|
||||||
|
return $node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function renderString($s, array $ctx) {
|
||||||
|
// Tag ocupando o valor inteiro => tipo nativo da variável
|
||||||
|
if (preg_match('/^\{\{\s*(.+?)\s*\}\}$/s', trim($s), $m) && trim($s) === $s) {
|
||||||
|
return self::evalExpr($m[1], $ctx);
|
||||||
|
}
|
||||||
|
// Tags embutidas => interpolação de string
|
||||||
|
return preg_replace_callback('/\{\{\s*(.+?)\s*\}\}/s', function ($m) use ($ctx) {
|
||||||
|
$v = self::evalExpr($m[1], $ctx);
|
||||||
|
if (is_array($v)) {
|
||||||
|
return json_encode($v, JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
if (is_bool($v)) {
|
||||||
|
return $v ? 'true' : 'false';
|
||||||
|
}
|
||||||
|
return (string) ($v ?? '');
|
||||||
|
}, $s);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function evalExpr($expr, array $ctx) {
|
||||||
|
// pipeline: var | filtro:arg | filtro2 ('|' entre aspas simples é preservado)
|
||||||
|
$parts = preg_split("/\|(?=(?:[^']*'[^']*')*[^']*$)/", $expr);
|
||||||
|
$value = self::resolveVar(trim(array_shift($parts)), $ctx);
|
||||||
|
foreach ($parts as $f) {
|
||||||
|
$value = self::applyFilter($value, trim($f));
|
||||||
|
}
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveVar($path, array $ctx) {
|
||||||
|
// lookup indexado: map.status[chamado.status]
|
||||||
|
if (preg_match('/^([a-z0-9_.]+)\[([a-z0-9_.]+)\]$/i', $path, $m)) {
|
||||||
|
$map = self::dig($ctx, $m[1]);
|
||||||
|
$key = self::dig($ctx, $m[2]);
|
||||||
|
if (!is_array($map) || $key === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $map[$key] ?? $map[(string) $key] ?? null;
|
||||||
|
}
|
||||||
|
return self::dig($ctx, $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function dig(array $ctx, $path) {
|
||||||
|
$cur = $ctx;
|
||||||
|
foreach (explode('.', $path) as $p) {
|
||||||
|
if (is_array($cur) && array_key_exists($p, $cur)) {
|
||||||
|
$cur = $cur[$p];
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function applyFilter($v, $filter) {
|
||||||
|
$name = $filter;
|
||||||
|
$arg = null;
|
||||||
|
if (strpos($filter, ':') !== false) {
|
||||||
|
[$name, $arg] = explode(':', $filter, 2);
|
||||||
|
$name = trim($name);
|
||||||
|
$arg = trim($arg);
|
||||||
|
if (preg_match("/^'(.*)'$/s", $arg, $m)) {
|
||||||
|
$arg = $m[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch ($name) {
|
||||||
|
case 'truncar':
|
||||||
|
return mb_substr((string) $v, 0, max(1, (int) $arg));
|
||||||
|
case 'sem_html':
|
||||||
|
return trim(strip_tags(html_entity_decode((string) $v)));
|
||||||
|
case 'minusculo':
|
||||||
|
return strtolower((string) $v);
|
||||||
|
case 'identificador':
|
||||||
|
return strtolower(preg_replace('/[^a-zA-Z0-9\-_]/', '-', (string) $v));
|
||||||
|
case 'padrao':
|
||||||
|
return ($v === null || $v === '' || $v === []) ? $arg : $v;
|
||||||
|
default:
|
||||||
|
// filtro desconhecido: no-op (não derruba o render)
|
||||||
|
return $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove nulls e arrays vazios (campos não definidos não vão no payload,
|
||||||
|
* igual ao motor 1.x). Strings vazias são preservadas.
|
||||||
|
*/
|
||||||
|
private static function prune($node) {
|
||||||
|
if (!is_array($node)) {
|
||||||
|
return $node;
|
||||||
|
}
|
||||||
|
$out = [];
|
||||||
|
foreach ($node as $k => $v) {
|
||||||
|
$v = self::prune($v);
|
||||||
|
if ($v === null || (is_array($v) && $v === [])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[$k] = $v;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('PLUGIN_REDMINE_VERSION', '1.6.0');
|
define('PLUGIN_REDMINE_VERSION', '2.0.0-dev');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the Mindplace License class if the autoloader has not run yet.
|
* Load the Mindplace License class if the autoloader has not run yet.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue