Compare commits
8 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e3ed338b5 | ||
|
|
7bf8cdab4e | ||
|
|
2c79d74545 | ||
|
|
aa5bf5eb75 | ||
|
|
5ee12c75d2 | ||
|
|
48600b9692 | ||
|
|
1053b9f506 | ||
|
|
7ac1f8ac44 |
15 changed files with 1593 additions and 48 deletions
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -4,6 +4,28 @@ 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] - 2026-07-03
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
- **Editor de Templates de Payload na config** (Fase 2): um card por operação com
|
||||||
|
editor **linha-a-linha** (caminho do campo → valor/tag) e toggle "Ver JSON",
|
||||||
|
botão **"Gerar template"** (seed + custom fields descobertos), ativar/desativar,
|
||||||
|
e **paleta de variáveis** clicável (copia a tag) com referência do ambiente
|
||||||
|
Redmine (trackers/status/prioridades/atividades/custom fields e valores).
|
||||||
|
- Operação `create_project` integrada ao motor de templates (contexto `form.*`
|
||||||
|
com os campos da aba, incluindo `form.cf_<id>` dos custom fields).
|
||||||
|
- **Testador de templates (Fase 3)**: botão "Testar" por operação com **preview**
|
||||||
|
do JSON renderizado (dados de exemplo + config real) e **teste real opcional**
|
||||||
|
(`ajax/template_test.php`) que cria e apaga um objeto de teste no Redmine e
|
||||||
|
**traduz os erros 422** em dicas acionáveis. Editor unificado num único "Salvar";
|
||||||
|
breadcrumb da config corrigido (Início > Configurar > Plugins).
|
||||||
|
|
||||||
## [1.6.0]
|
## [1.6.0]
|
||||||
|
|
||||||
### Adicionado
|
### Adicionado
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ TicketTask "Feito" ────────────────────
|
||||||
- ✅ **Observadores padrão** configuráveis nas issues.
|
- ✅ **Observadores padrão** configuráveis nas issues.
|
||||||
- ✅ Chave de API **criptografada** em repouso (GLPIKey).
|
- ✅ Chave de API **criptografada** em repouso (GLPIKey).
|
||||||
- ✅ Interface 100% nativa do GLPI 11 (componentes Twig).
|
- ✅ Interface 100% nativa do GLPI 11 (componentes Twig).
|
||||||
|
- ✅ **Templates de Payload (2.0)** — personalize o JSON enviado ao Redmine em cada operação, com **variáveis do GLPI** e **descoberta automática dos campos do seu Redmine** (inclusive campos personalizados obrigatórios). Editor visual + testador embutido. Veja [`docs/TEMPLATES.md`](docs/TEMPLATES.md).
|
||||||
|
|
||||||
## Requisitos
|
## Requisitos
|
||||||
|
|
||||||
|
|
|
||||||
155
ajax/template_test.php
Normal file
155
ajax/template_test.php
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Testador de templates (Fase 3).
|
||||||
|
* - action=preview: renderiza o template com DADOS DE EXEMPLO e devolve o JSON final.
|
||||||
|
* - action=dryrun : teste real — cria e apaga um objeto de teste no Redmine,
|
||||||
|
* traduzindo os erros 422 em dicas acionáveis.
|
||||||
|
*
|
||||||
|
* GLPI 11: o kernel já bootou o core e validou o CSRF do XHR via header
|
||||||
|
* X-Glpi-Csrf-Token (não incluir inc/includes.php).
|
||||||
|
*/
|
||||||
|
|
||||||
|
Session::checkRight('config', UPDATE);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
function rm_out(array $data): void {
|
||||||
|
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$op = (string) ($_POST['operation'] ?? '');
|
||||||
|
$act = (string) ($_POST['action'] ?? 'preview');
|
||||||
|
if (!in_array($op, PluginRedmineTemplateengine::OPERATIONS, true)) {
|
||||||
|
rm_out(['success' => false, 'stage' => 'input', 'message' => 'Operação inválida.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = (string) ($_POST['template_json'] ?? '');
|
||||||
|
if ($json !== '' && json_decode($json, true) === null && json_decode(stripslashes($json), true) !== null) {
|
||||||
|
$json = stripslashes($json);
|
||||||
|
}
|
||||||
|
if ($json === '') {
|
||||||
|
rm_out(['success' => false, 'stage' => 'input', 'message' => 'Template vazio — gere ou edite o template antes de testar.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- render com dados de exemplo (sempre) ----
|
||||||
|
$ctx = PluginRedmineTemplateengine::sampleContext($op);
|
||||||
|
try {
|
||||||
|
$payload = PluginRedmineTemplateengine::render($json, $ctx);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
rm_out(['success' => false, 'stage' => 'render', 'message' => 'Erro no render: ' . $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($act === 'preview') {
|
||||||
|
rm_out([
|
||||||
|
'success' => true,
|
||||||
|
'stage' => 'preview',
|
||||||
|
'payload' => $payload,
|
||||||
|
'note' => 'Renderizado com DADOS DE EXEMPLO (chamado #4321, tarefa de 1.5h...) + sua configuração real.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- teste real (dry-run com criação + exclusão) ----
|
||||||
|
$target = (int) ($_POST['target_project'] ?? 0);
|
||||||
|
|
||||||
|
$hints = function (array $errors): array {
|
||||||
|
$out = [];
|
||||||
|
foreach ($errors as $e) {
|
||||||
|
$e = (string) $e;
|
||||||
|
if (mb_stripos($e, 'não pode ficar vazio') !== false || mb_stripos($e, "can't be blank") !== false) {
|
||||||
|
$out[] = "$e → inclua/mapeie esse campo no template (veja \"Campos que o Redmine espera\").";
|
||||||
|
} elseif (mb_stripos($e, 'não está incluso na lista') !== false || mb_stripos($e, 'not included in the list') !== false) {
|
||||||
|
$out[] = "$e → valor não aceito: confira os valores possíveis no dicionário e se está habilitado no projeto alvo.";
|
||||||
|
} elseif (mb_stripos($e, 'não é válido') !== false || mb_stripos($e, 'is invalid') !== false) {
|
||||||
|
$out[] = "$e → formato inválido (tipo errado? identificador com maiúsculas?).";
|
||||||
|
} else {
|
||||||
|
$out[] = $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
};
|
||||||
|
|
||||||
|
$reject = function (array $r) use ($hints, $payload) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'stage' => 'dryrun',
|
||||||
|
'message' => "O Redmine RECUSOU o payload (HTTP {$r['code']}).",
|
||||||
|
'errors' => $hints($r['body']['errors'] ?? [($r['raw'] !== '' ? $r['raw'] : 'sem resposta da API')]),
|
||||||
|
'sent' => $payload,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
$result = ['success' => false, 'stage' => 'dryrun', 'message' => 'Nada executado.'];
|
||||||
|
$cleanup = [];
|
||||||
|
|
||||||
|
switch ($op) {
|
||||||
|
case 'create_project':
|
||||||
|
// nome/identifier de teste para não colidir nem confundir
|
||||||
|
$payload['project']['name'] = '[TESTE TEMPLATE] ' . ($payload['project']['name'] ?? 'teste');
|
||||||
|
$payload['project']['identifier'] = 'glpi-tpl-test-' . time();
|
||||||
|
$r = PluginRedmineRedmineapi::rawRequest('POST', '/projects.json', $payload);
|
||||||
|
if ($r['code'] === 201 && isset($r['body']['project']['id'])) {
|
||||||
|
$pid = (int) $r['body']['project']['id'];
|
||||||
|
$del = PluginRedmineRedmineapi::rawRequest('DELETE', "/projects/{$pid}.json");
|
||||||
|
$result = ['success' => true, 'stage' => 'dryrun', 'sent' => $payload,
|
||||||
|
'message' => "O Redmine ACEITOU o payload ✔ (projeto de teste #{$pid} criado e apagado — DELETE {$del['code']})."];
|
||||||
|
} else {
|
||||||
|
$result = $reject($r);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'create_issue':
|
||||||
|
if ($target <= 0) {
|
||||||
|
rm_out(['success' => false, 'stage' => 'input', 'message' => 'Escolha o projeto alvo para o teste real.']);
|
||||||
|
}
|
||||||
|
$payload['issue']['project_id'] = $target;
|
||||||
|
$payload['issue']['subject'] = '[TESTE TEMPLATE] ' . ($payload['issue']['subject'] ?? 'teste');
|
||||||
|
$r = PluginRedmineRedmineapi::rawRequest('POST', '/issues.json', $payload);
|
||||||
|
if ($r['code'] === 201 && isset($r['body']['issue']['id'])) {
|
||||||
|
$iid = (int) $r['body']['issue']['id'];
|
||||||
|
$del = PluginRedmineRedmineapi::rawRequest('DELETE', "/issues/{$iid}.json");
|
||||||
|
$result = ['success' => true, 'stage' => 'dryrun', 'sent' => $payload,
|
||||||
|
'message' => "O Redmine ACEITOU o payload ✔ (issue de teste #{$iid} criada e apagada — DELETE {$del['code']})."];
|
||||||
|
} else {
|
||||||
|
$result = $reject($r);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'log_time':
|
||||||
|
case 'update_status':
|
||||||
|
if ($target <= 0) {
|
||||||
|
rm_out(['success' => false, 'stage' => 'input', 'message' => 'Escolha o projeto alvo para o teste real.']);
|
||||||
|
}
|
||||||
|
// issue temporária no projeto alvo
|
||||||
|
$tmp = PluginRedmineRedmineapi::rawRequest('POST', '/issues.json', [
|
||||||
|
'issue' => ['project_id' => $target, 'subject' => '[TESTE TEMPLATE] alvo temporário'],
|
||||||
|
]);
|
||||||
|
if ($tmp['code'] !== 201 || !isset($tmp['body']['issue']['id'])) {
|
||||||
|
$result = ['success' => false, 'stage' => 'dryrun',
|
||||||
|
'message' => "Não consegui criar a issue temporária no projeto alvo (HTTP {$tmp['code']}).",
|
||||||
|
'errors' => $hints($tmp['body']['errors'] ?? [])];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$iid = (int) $tmp['body']['issue']['id'];
|
||||||
|
$cleanup[] = "/issues/{$iid}.json";
|
||||||
|
|
||||||
|
if ($op === 'log_time') {
|
||||||
|
$payload['time_entry']['issue_id'] = $iid;
|
||||||
|
$r = PluginRedmineRedmineapi::rawRequest('POST', '/time_entries.json', $payload);
|
||||||
|
$ok = ($r['code'] === 201);
|
||||||
|
} else {
|
||||||
|
$r = PluginRedmineRedmineapi::rawRequest('PUT', "/issues/{$iid}.json", $payload);
|
||||||
|
$ok = ($r['code'] >= 200 && $r['code'] < 300);
|
||||||
|
}
|
||||||
|
$result = $ok
|
||||||
|
? ['success' => true, 'stage' => 'dryrun', 'sent' => $payload,
|
||||||
|
'message' => "O Redmine ACEITOU o payload ✔ (testado na issue temporária #{$iid}, apagada em seguida)."]
|
||||||
|
: $reject($r);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// limpeza garantida antes de responder
|
||||||
|
foreach ($cleanup as $path) {
|
||||||
|
PluginRedmineRedmineapi::rawRequest('DELETE', $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
rm_out($result);
|
||||||
31
docs/API.md
31
docs/API.md
|
|
@ -280,3 +280,34 @@ Content-Type: application/json
|
||||||
3. (1ª vez) `POST /issues.json` cria a issue — autor = técnico atribuído (impersonation + auto-membership).
|
3. (1ª vez) `POST /issues.json` cria a issue — autor = técnico atribuído (impersonation + auto-membership).
|
||||||
4. `POST /time_entries.json` lança as horas — autor = quem lançou a task.
|
4. `POST /time_entries.json` lança as horas — autor = quem lançou a task.
|
||||||
5. Ao mudar o status do chamado, `PUT /issues/{id}.json` reflete no Redmine.
|
5. Ao mudar o status do chamado, `PUT /issues/{id}.json` reflete no Redmine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Motor de Templates de Payload (2.0)
|
||||||
|
|
||||||
|
A partir da 2.0, o corpo de cada operação pode vir de um **template** editável em
|
||||||
|
vez de ser montado no código. Guia do usuário: [`TEMPLATES.md`](TEMPLATES.md).
|
||||||
|
|
||||||
|
### Componentes internos
|
||||||
|
- **`PluginRedmineTemplateengine`** (`inc/templateengine.class.php`): armazenamento
|
||||||
|
(`glpi_plugin_redmine_templates`), `defaultTemplate()`/`mergeDiscoveredCustomFields()`
|
||||||
|
(seeds), `variableCatalog()` e `fieldDictionary()` (paleta "DE" e dicionário "PARA"),
|
||||||
|
`buildContext()`/`buildFormContext()`/`sampleContext()` (contextos), e o renderizador
|
||||||
|
`render()` (tags mustache-like, tipo nativo p/ tag inteira, filtros, `prune` de nulos).
|
||||||
|
- **`PluginRedmineRedmineapi::createIssueFromPayload()` / `logTimeFromPayload()`**:
|
||||||
|
enviam um payload já renderizado (com fallback de impersonation → admin).
|
||||||
|
- **`PluginRedmineRedmineapi::rawRequest()`**: requisição crua (código+corpo) usada
|
||||||
|
pelo testador — o erro **é** a informação.
|
||||||
|
- **`ajax/template_test.php`**: `action=preview` (render com `sampleContext`) e
|
||||||
|
`action=dryrun` (cria+apaga objeto de teste, traduz 422 em dicas).
|
||||||
|
|
||||||
|
### Precedência (motor 2.0 × 1.x)
|
||||||
|
Em cada operação do hook/controllers:
|
||||||
|
1. Existe template **ativo** para a operação? Renderiza e envia esse payload.
|
||||||
|
2. Falha no **render** (JSON/tag inválidos)? Loga e **cai no comportamento 1.x**.
|
||||||
|
3. Sem template ativo? Comportamento **1.x** (idêntico às versões anteriores).
|
||||||
|
|
||||||
|
### Descoberta de campos
|
||||||
|
`GET /custom_fields.json` (admin) é cacheado por `syncMetadata()` — os campos
|
||||||
|
personalizados de `project`, `issue` e `time_entry` alimentam tanto o seed do
|
||||||
|
"Gerar template" (só os **obrigatórios**) quanto o dicionário "PARA" (todos).
|
||||||
|
|
|
||||||
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 (concluída):** 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.
|
||||||
106
docs/TEMPLATES.md
Normal file
106
docs/TEMPLATES.md
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
# Templates de Payload (2.0)
|
||||||
|
|
||||||
|
A partir da versão 2.0, o plugin deixa de ter o corpo das requisições ao Redmine
|
||||||
|
"chumbado" no código: cada operação passa a ter um **template de payload** editável,
|
||||||
|
para que o plugin se adapte às **peculiaridades de cada Redmine** (campos
|
||||||
|
personalizados obrigatórios, campos extras, textos fixos) sem precisar de alteração
|
||||||
|
de código.
|
||||||
|
|
||||||
|
> **Retrocompatível:** sem template **ativo**, o plugin se comporta **exatamente**
|
||||||
|
> como nas versões 1.x. Você adota os templates quando (e se) quiser.
|
||||||
|
|
||||||
|
## Onde fica
|
||||||
|
|
||||||
|
**Configurar → Plugins → Redmine Integration → "Templates de Payload (avançado)"**.
|
||||||
|
|
||||||
|
Há um card por operação:
|
||||||
|
|
||||||
|
| Operação | Quando dispara | Endpoint Redmine |
|
||||||
|
|---|---|---|
|
||||||
|
| **Criar tarefa (issue)** | 1º lançamento de tempo de um chamado | `POST /issues.json` |
|
||||||
|
| **Lançar tempo** | tarefa (TicketTask) marcada como *Feito* | `POST /time_entries.json` |
|
||||||
|
| **Atualizar status** | mudança de status do chamado | `PUT /issues/{id}.json` |
|
||||||
|
| **Criar projeto** | vínculo "criar novo" na aba Redmine | `POST /projects.json` |
|
||||||
|
|
||||||
|
## O conceito: "DE → PARA"
|
||||||
|
|
||||||
|
- **DE (variáveis do GLPI):** o que o plugin tem para oferecer — `chamado.titulo`,
|
||||||
|
`tarefa.horas`, `vinculo.projeto_redmine`, etc. Aparecem como **autocomplete** no
|
||||||
|
campo de valor de cada linha (e há uma lista de referência colapsável).
|
||||||
|
- **PARA (campos do Redmine):** o que **o seu Redmine** espera em cada operação —
|
||||||
|
descoberto automaticamente (campos padrão + **campos personalizados** via
|
||||||
|
`GET /custom_fields.json`), com marcação de **obrigatório** e valores possíveis.
|
||||||
|
Clicar num campo do "PARA" adiciona a linha no editor com o caminho já pronto.
|
||||||
|
|
||||||
|
## Fluxo recomendado
|
||||||
|
|
||||||
|
1. **Sincronizar Metadados** (aba Conexão) — é o que descobre trackers, status,
|
||||||
|
atividades e **campos personalizados** do seu Redmine.
|
||||||
|
2. No card da operação, clique em **Gerar template** — cria o ponto de partida
|
||||||
|
equivalente ao comportamento padrão **+ os campos obrigatórios do seu ambiente**.
|
||||||
|
3. Ajuste as linhas (o autocomplete ajuda a achar as variáveis do GLPI).
|
||||||
|
4. Clique em **Testar**:
|
||||||
|
- **Preview** — mostra o JSON final renderizado com dados de exemplo (seguro).
|
||||||
|
- **Teste real** (opcional) — cria e apaga um objeto de teste no Redmine e,
|
||||||
|
se recusado, **traduz o erro** ("Atividade não pode ficar vazio → mapeie esse campo").
|
||||||
|
5. Marque **Ativo** e clique em **Salvar** (o botão único no fim da página grava tudo).
|
||||||
|
|
||||||
|
## Sintaxe das tags
|
||||||
|
|
||||||
|
```
|
||||||
|
{{ variavel }} → valor da variável
|
||||||
|
{{ variavel | filtro }} → aplica um filtro
|
||||||
|
{{ variavel | filtro:arg }} → filtro com argumento
|
||||||
|
{{ mapa[chave.dinamica] }} → lookup indexado (ex.: map.status[chamado.status])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Regra de tipos:** quando a tag ocupa **todo** o valor, o resultado sai com o
|
||||||
|
**tipo nativo** da variável (número, array, booleano — sem aspas no JSON). Quando a
|
||||||
|
tag está no meio de um texto, vira interpolação de string.
|
||||||
|
Campos que resolverem para vazio/nulo são **omitidos** do payload.
|
||||||
|
|
||||||
|
### Filtros disponíveis
|
||||||
|
|
||||||
|
| Filtro | Efeito |
|
||||||
|
|---|---|
|
||||||
|
| `truncar:N` | corta a string em N caracteres (ex.: `subject` no limite de 255) |
|
||||||
|
| `sem_html` | remove HTML/entidades |
|
||||||
|
| `minusculo` | tudo minúsculo |
|
||||||
|
| `identificador` | normaliza para identificador Redmine válido (minúsculas, `-`) |
|
||||||
|
| `padrao:'texto'` | valor fixo quando a variável estiver vazia |
|
||||||
|
|
||||||
|
## Variáveis do GLPI (o "DE")
|
||||||
|
|
||||||
|
| Grupo | Variáveis |
|
||||||
|
|---|---|
|
||||||
|
| `chamado.*` | `id`, `titulo`, `descricao`, `status`, `entidade`, `tecnico` |
|
||||||
|
| `tarefa.*` | `id`, `horas`, `segundos`, `descricao`, `comentario`, `data`, `autor` |
|
||||||
|
| `vinculo.*` | `projeto_redmine`, `issue_redmine`, `categoria_padrao` |
|
||||||
|
| `config.*` | `tracker_padrao`, `prioridade_padrao`, `atividade_padrao`, `observadores` |
|
||||||
|
| `map.status` | mapa de status GLPI→Redmine (use como `map.status[chamado.status]`) |
|
||||||
|
| `form.*` (criar projeto) | `nome`, `identificador`, `descricao`, `publico`, `subprojeto_de`, `trackers`, `modulos`, `cf_<id>` |
|
||||||
|
|
||||||
|
> **Autoria:** `chamado.tecnico` e `tarefa.autor` são **texto** (para usar em
|
||||||
|
> comentários, por exemplo). A **autoria real** da issue/tempo continua automática,
|
||||||
|
> via *impersonation* — não é um campo do template.
|
||||||
|
|
||||||
|
## Segurança e robustez
|
||||||
|
|
||||||
|
- As tags são um mini-motor de substituição (mustache-like), **não** executam código
|
||||||
|
(não é Twig completo) — seguro para edição por administradores.
|
||||||
|
- Template com JSON inválido **não é salvo** (aviso na tela).
|
||||||
|
- Se um template ativo falhar no **render** em produção, o plugin **cai no
|
||||||
|
comportamento 1.x** e registra no log — nunca perde um lançamento.
|
||||||
|
|
||||||
|
## Exemplo (o que o "Gerar template" produz para `log_time`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"time_entry": {
|
||||||
|
"issue_id": "{{ vinculo.issue_redmine }}",
|
||||||
|
"hours": "{{ tarefa.horas }}",
|
||||||
|
"comments": "{{ tarefa.comentario }}",
|
||||||
|
"activity_id": "{{ config.atividade_padrao }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
@ -10,6 +10,30 @@ if (isset($_POST["update"]) || isset($_POST["sync"])) {
|
||||||
// Save whatever was typed (URL/key/defaults) before anything else.
|
// Save whatever was typed (URL/key/defaults) before anything else.
|
||||||
$config->update($_POST);
|
$config->update($_POST);
|
||||||
|
|
||||||
|
// Templates de payload: salvos junto pelo botão Salvar único.
|
||||||
|
if (!empty($_POST['templates_json']) && is_array($_POST['templates_json'])) {
|
||||||
|
foreach ($_POST['templates_json'] as $op => $json) {
|
||||||
|
if (!in_array($op, PluginRedmineTemplateengine::OPERATIONS, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$json = (string) $json;
|
||||||
|
// Defensivo contra escaping de aspas na camada de entrada.
|
||||||
|
if ($json !== '' && json_decode($json, true) === null && json_decode(stripslashes($json), true) !== null) {
|
||||||
|
$json = stripslashes($json);
|
||||||
|
}
|
||||||
|
if ($json !== '' && json_decode($json, true) === null) {
|
||||||
|
Session::addMessageAfterRedirect(
|
||||||
|
sprintf(__('Template "%s" não é JSON válido — não foi salvo.', 'redmine'), $op),
|
||||||
|
false,
|
||||||
|
ERROR
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$active = !empty($_POST['templates_active'][$op]) && $json !== '';
|
||||||
|
PluginRedmineTemplateengine::save($op, $json, $active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isset($_POST["sync"])) {
|
if (isset($_POST["sync"])) {
|
||||||
$metadata = PluginRedmineRedmineapi::syncMetadata();
|
$metadata = PluginRedmineRedmineapi::syncMetadata();
|
||||||
if ($metadata !== false) {
|
if ($metadata !== false) {
|
||||||
|
|
@ -47,7 +71,9 @@ if (isset($_POST["del_usermap"])) {
|
||||||
Html::back();
|
Html::back();
|
||||||
}
|
}
|
||||||
|
|
||||||
Html::header('Redmine Config', $_SERVER['PHP_SELF'], "config", "plugins");
|
// sector 'config' + item 'plugin' = breadcrumb "Início > Configurar > Plugins"
|
||||||
|
// (mesmas chaves de front/plugin.php do core; 'plugins' no plural não existe no menu)
|
||||||
|
Html::header(__('Redmine Integration', 'redmine'), $_SERVER['PHP_SELF'], 'config', 'plugin');
|
||||||
|
|
||||||
$config->showForm(1);
|
$config->showForm(1);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,24 @@ if (isset($_POST["action"])) {
|
||||||
Session::addMessageAfterRedirect(__('Entidade vinculada com sucesso!', 'redmine'));
|
Session::addMessageAfterRedirect(__('Entidade vinculada com sucesso!', 'redmine'));
|
||||||
} else {
|
} else {
|
||||||
// CREATE OPERATION
|
// CREATE OPERATION
|
||||||
|
$new_redmine_id = false;
|
||||||
|
|
||||||
|
// Motor 2.0: template ativo tem precedência; erro de RENDER cai no 1.x.
|
||||||
|
$tpl = PluginRedmineTemplateengine::getActive('create_project');
|
||||||
|
if ($tpl) {
|
||||||
|
try {
|
||||||
|
$ctx = PluginRedmineTemplateengine::buildFormContext($_POST);
|
||||||
|
$rendered = PluginRedmineTemplateengine::render($tpl['template'], $ctx);
|
||||||
|
if (!empty($rendered['project'])) {
|
||||||
|
$new_redmine_id = PluginRedmineRedmineapi::createProjectFull($rendered['project']);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Toolbox::logInFile('redmine', "Template create_project falhou no render (" . $e->getMessage() . ") — usando motor 1.x.\n");
|
||||||
|
$tpl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$tpl) {
|
||||||
$payload = [
|
$payload = [
|
||||||
'name' => $_POST['name'],
|
'name' => $_POST['name'],
|
||||||
'identifier' => $_POST['identifier'],
|
'identifier' => $_POST['identifier'],
|
||||||
|
|
@ -93,6 +111,8 @@ if (isset($_POST["action"])) {
|
||||||
}
|
}
|
||||||
|
|
||||||
$new_redmine_id = PluginRedmineRedmineapi::createProjectFull($payload);
|
$new_redmine_id = PluginRedmineRedmineapi::createProjectFull($payload);
|
||||||
|
} // fim do motor 1.x
|
||||||
|
|
||||||
if ($new_redmine_id) {
|
if ($new_redmine_id) {
|
||||||
$DB->insert('glpi_plugin_redmine_entities', [
|
$DB->insert('glpi_plugin_redmine_entities', [
|
||||||
'entities_id' => $entities_id,
|
'entities_id' => $entities_id,
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,24 @@ if (isset($_POST["action"])) {
|
||||||
Session::addMessageAfterRedirect("Projeto vinculado com sucesso!");
|
Session::addMessageAfterRedirect("Projeto vinculado com sucesso!");
|
||||||
} else {
|
} else {
|
||||||
// CREATE OPERATION
|
// CREATE OPERATION
|
||||||
|
$new_redmine_id = false;
|
||||||
|
|
||||||
|
// Motor 2.0: template ativo tem precedência; erro de RENDER cai no 1.x.
|
||||||
|
$tpl = PluginRedmineTemplateengine::getActive('create_project');
|
||||||
|
if ($tpl) {
|
||||||
|
try {
|
||||||
|
$ctx = PluginRedmineTemplateengine::buildFormContext($_POST);
|
||||||
|
$rendered = PluginRedmineTemplateengine::render($tpl['template'], $ctx);
|
||||||
|
if (!empty($rendered['project'])) {
|
||||||
|
$new_redmine_id = PluginRedmineRedmineapi::createProjectFull($rendered['project']);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Toolbox::logInFile('redmine', "Template create_project falhou no render (" . $e->getMessage() . ") — usando motor 1.x.\n");
|
||||||
|
$tpl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$tpl) {
|
||||||
$payload = [
|
$payload = [
|
||||||
'name' => $_POST['name'],
|
'name' => $_POST['name'],
|
||||||
'identifier' => $_POST['identifier'],
|
'identifier' => $_POST['identifier'],
|
||||||
|
|
@ -100,6 +118,7 @@ if (isset($_POST["action"])) {
|
||||||
}
|
}
|
||||||
|
|
||||||
$new_redmine_id = PluginRedmineRedmineapi::createProjectFull($payload);
|
$new_redmine_id = PluginRedmineRedmineapi::createProjectFull($payload);
|
||||||
|
} // fim do motor 1.x
|
||||||
|
|
||||||
if ($new_redmine_id) {
|
if ($new_redmine_id) {
|
||||||
global $DB;
|
global $DB;
|
||||||
|
|
|
||||||
159
hook.php
159
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,38 +410,57 @@ function _plugin_redmine_process_tickettask(TicketTask $task) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$issueId) {
|
if (!$issueId) {
|
||||||
// Build the issue from the ticket + the configured defaults.
|
|
||||||
$cfg = PluginRedmineConfig::getConfigRow();
|
|
||||||
$extra = [];
|
|
||||||
if (!empty($cfg['default_tracker_id'])) { $extra['tracker_id'] = (int) $cfg['default_tracker_id']; }
|
|
||||||
if (!empty($cfg['default_priority_id'])) { $extra['priority_id'] = (int) $cfg['default_priority_id']; }
|
|
||||||
|
|
||||||
// Category is per link (project or entity).
|
|
||||||
if ($linkRow && !empty($linkRow['default_category_id'])) {
|
|
||||||
$extra['category_id'] = (int) $linkRow['default_category_id'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issue status mirrors the GLPI ticket status (mapped).
|
|
||||||
$mappedStatus = PluginRedmineConfig::mapTicketStatus((int) $ticket->fields['status']);
|
|
||||||
if ($mappedStatus) { $extra['status_id'] = $mappedStatus; }
|
|
||||||
|
|
||||||
// Default watchers come from the config (Redmine user ids).
|
|
||||||
$watchers = PluginRedmineConfig::getDefaultWatcherIds();
|
|
||||||
if (!empty($watchers)) { $extra['watcher_user_ids'] = $watchers; }
|
|
||||||
|
|
||||||
// Author = the GLPI assigned technician, via impersonation.
|
// Author = the GLPI assigned technician, via impersonation.
|
||||||
$techId = _plugin_redmine_assigned_tech($ticketId);
|
$techId = _plugin_redmine_assigned_tech($ticketId);
|
||||||
$switchUser = _plugin_redmine_impersonation_login($techId, $redmineProjectId);
|
$switchUser = _plugin_redmine_impersonation_login($techId, $redmineProjectId);
|
||||||
|
|
||||||
// Subject = ticket title; description stripped of HTML.
|
// Motor 2.0: template ativo tem precedência. Erro de RENDER cai no 1.x;
|
||||||
$description = strip_tags(html_entity_decode((string) $ticket->fields['content']));
|
// template válido é a palavra final (sem retry pela via legada).
|
||||||
$issueId = PluginRedmineRedmineapi::createIssue(
|
$tpl = PluginRedmineTemplateengine::getActive('create_issue');
|
||||||
$redmineProjectId,
|
if ($tpl) {
|
||||||
$ticket->fields['name'],
|
try {
|
||||||
$description,
|
$ctx = PluginRedmineTemplateengine::buildContext($ticket, $task, $linkRow, $redmineProjectId);
|
||||||
$extra,
|
$payload = PluginRedmineTemplateengine::render($tpl['template'], $ctx);
|
||||||
$switchUser
|
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();
|
||||||
|
$extra = [];
|
||||||
|
if (!empty($cfg['default_tracker_id'])) { $extra['tracker_id'] = (int) $cfg['default_tracker_id']; }
|
||||||
|
if (!empty($cfg['default_priority_id'])) { $extra['priority_id'] = (int) $cfg['default_priority_id']; }
|
||||||
|
|
||||||
|
// Category is per link (project or entity).
|
||||||
|
if ($linkRow && !empty($linkRow['default_category_id'])) {
|
||||||
|
$extra['category_id'] = (int) $linkRow['default_category_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue status mirrors the GLPI ticket status (mapped).
|
||||||
|
$mappedStatus = PluginRedmineConfig::mapTicketStatus((int) $ticket->fields['status']);
|
||||||
|
if ($mappedStatus) { $extra['status_id'] = $mappedStatus; }
|
||||||
|
|
||||||
|
// Default watchers come from the config (Redmine user ids).
|
||||||
|
$watchers = PluginRedmineConfig::getDefaultWatcherIds();
|
||||||
|
if (!empty($watchers)) { $extra['watcher_user_ids'] = $watchers; }
|
||||||
|
|
||||||
|
// 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) {
|
if ($issueId) {
|
||||||
$DB->insert('glpi_plugin_redmine_tickets', [
|
$DB->insert('glpi_plugin_redmine_tickets', [
|
||||||
'tickets_id' => $ticketId,
|
'tickets_id' => $ticketId,
|
||||||
|
|
@ -425,26 +474,46 @@ function _plugin_redmine_process_tickettask(TicketTask $task) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$cfg = PluginRedmineConfig::getConfigRow();
|
|
||||||
$hours = round($task->fields['actiontime'] / HOUR_TIMESTAMP, 2);
|
|
||||||
|
|
||||||
// Comments are mandatory on time entries in some Redmine setups — never send empty.
|
|
||||||
$comments = trim(strip_tags(html_entity_decode((string) ($task->fields['content'] ?? ''))));
|
|
||||||
if ($comments === '') {
|
|
||||||
$comments = "Tempo lançado via GLPI (chamado #{$ticketId})";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Time entry author = the GLPI user who logged the task, via impersonation.
|
// Time entry author = the GLPI user who logged the task, via impersonation.
|
||||||
$taskUserId = (int) ($task->fields['users_id'] ?? 0);
|
$taskUserId = (int) ($task->fields['users_id'] ?? 0);
|
||||||
$timeSwitchUser = _plugin_redmine_impersonation_login($taskUserId, $redmineProjectId);
|
$timeSwitchUser = _plugin_redmine_impersonation_login($taskUserId, $redmineProjectId);
|
||||||
|
|
||||||
$timeEntryId = PluginRedmineRedmineapi::logTime(
|
$timeEntryId = null;
|
||||||
$issueId,
|
|
||||||
$hours,
|
// Motor 2.0: template ativo tem precedência; erro de render cai no 1.x.
|
||||||
$comments,
|
$tpl = PluginRedmineTemplateengine::getActive('log_time');
|
||||||
!empty($cfg['default_activity_id']) ? (int) $cfg['default_activity_id'] : null,
|
if ($tpl) {
|
||||||
$timeSwitchUser
|
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();
|
||||||
|
$hours = round($task->fields['actiontime'] / HOUR_TIMESTAMP, 2);
|
||||||
|
|
||||||
|
// Comments are mandatory on time entries in some Redmine setups — never send empty.
|
||||||
|
$comments = trim(strip_tags(html_entity_decode((string) ($task->fields['content'] ?? ''))));
|
||||||
|
if ($comments === '') {
|
||||||
|
$comments = "Tempo lançado via GLPI (chamado #{$ticketId})";
|
||||||
|
}
|
||||||
|
|
||||||
|
$timeEntryId = PluginRedmineRedmineapi::logTime(
|
||||||
|
$issueId,
|
||||||
|
$hours,
|
||||||
|
$comments,
|
||||||
|
!empty($cfg['default_activity_id']) ? (int) $cfg['default_activity_id'] : null,
|
||||||
|
$timeSwitchUser
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ($timeEntryId) {
|
if ($timeEntryId) {
|
||||||
$DB->insert('glpi_plugin_redmine_tasks', [
|
$DB->insert('glpi_plugin_redmine_tasks', [
|
||||||
|
|
|
||||||
|
|
@ -196,7 +196,42 @@ class PluginRedmineConfig extends CommonDBTM {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Templates de payload (motor 2.0) + paleta de variáveis
|
||||||
|
$tpl_labels = [
|
||||||
|
'create_issue' => __('Criar tarefa (issue)', 'redmine'),
|
||||||
|
'log_time' => __('Lançar tempo', 'redmine'),
|
||||||
|
'update_status' => __('Atualizar status', 'redmine'),
|
||||||
|
'create_project' => __('Criar projeto', 'redmine'),
|
||||||
|
];
|
||||||
|
$templates = [];
|
||||||
|
foreach (PluginRedmineTemplateengine::OPERATIONS as $op) {
|
||||||
|
$row = PluginRedmineTemplateengine::getRow($op);
|
||||||
|
$seed = PluginRedmineTemplateengine::mergeDiscoveredCustomFields(
|
||||||
|
$op,
|
||||||
|
PluginRedmineTemplateengine::defaultTemplate($op)
|
||||||
|
);
|
||||||
|
$templates[$op] = [
|
||||||
|
'label' => $tpl_labels[$op] ?? $op,
|
||||||
|
'template' => (string) ($row['template'] ?? ''),
|
||||||
|
'is_active' => (int) ($row['is_active'] ?? 0),
|
||||||
|
// seed pro botão "Gerar template" (ação local no browser)
|
||||||
|
'seed' => json_encode($seed, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dicionário "PARA" por operação: campos que o Redmine espera/aceita
|
||||||
|
$field_dicts = [];
|
||||||
|
foreach (PluginRedmineTemplateengine::OPERATIONS as $op) {
|
||||||
|
$field_dicts[$op] = PluginRedmineTemplateengine::fieldDictionary($op);
|
||||||
|
}
|
||||||
|
|
||||||
|
global $CFG_GLPI;
|
||||||
\Glpi\Application\View\TemplateRenderer::getInstance()->display('@redmine/config_form.html.twig', [
|
\Glpi\Application\View\TemplateRenderer::getInstance()->display('@redmine/config_form.html.twig', [
|
||||||
|
'templates' => $templates,
|
||||||
|
'palette_vars' => PluginRedmineTemplateengine::variableCatalog(),
|
||||||
|
'field_dicts' => $field_dicts,
|
||||||
|
'ajax_test_url' => $CFG_GLPI['root_doc'] . '/plugins/redmine/ajax/template_test.php',
|
||||||
|
'test_project_options' => PluginRedmineProject::getRedmineProjectOptions(true),
|
||||||
'action_url' => Toolbox::getItemTypeFormURL(__CLASS__),
|
'action_url' => Toolbox::getItemTypeFormURL(__CLASS__),
|
||||||
'csrf_token_value' => Session::getNewCSRFToken(),
|
'csrf_token_value' => Session::getNewCSRFToken(),
|
||||||
'redmine_url' => $config['redmine_url'] ?? '',
|
'redmine_url' => $config['redmine_url'] ?? '',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -316,6 +344,45 @@ class PluginRedmineRedmineapi {
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requisição crua para o testador de templates: devolve código HTTP e corpo
|
||||||
|
* (o post()/get() normais escondem o erro; aqui o erro É a informação).
|
||||||
|
* @return array ['code'=>int,'body'=>array|null,'raw'=>string]
|
||||||
|
*/
|
||||||
|
public static function rawRequest($method, $endpoint, ?array $payload = null) {
|
||||||
|
$config = self::getConfig();
|
||||||
|
if (!$config || empty($config['redmine_url']) || empty($config['api_key'])) {
|
||||||
|
return ['code' => 0, 'body' => null, 'raw' => 'plugin não configurado'];
|
||||||
|
}
|
||||||
|
$ch = curl_init(rtrim($config['redmine_url'], '/') . $endpoint);
|
||||||
|
$opts = [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CUSTOMREQUEST => strtoupper($method),
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'X-Redmine-API-Key: ' . $config['api_key'],
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 8,
|
||||||
|
];
|
||||||
|
if ($payload !== null) {
|
||||||
|
$opts[CURLOPT_POSTFIELDS] = json_encode($payload, JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
curl_setopt_array($ch, $opts);
|
||||||
|
$raw = (string) curl_exec($ch);
|
||||||
|
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
return ['code' => $code, 'body' => json_decode($raw, true), 'raw' => $raw];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All custom field definitions (project, issue, time_entry...). Admin only.
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function getAllCustomFields() {
|
||||||
|
$result = self::get("/custom_fields.json");
|
||||||
|
return $result['custom_fields'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure a Redmine user is a member of a project (idempotent).
|
* Ensure a Redmine user is a member of a project (idempotent).
|
||||||
* Impersonation requires the user to be a project member with a role.
|
* Impersonation requires the user to be a project member with a role.
|
||||||
|
|
@ -390,6 +457,7 @@ class PluginRedmineRedmineapi {
|
||||||
'activities' => self::getActivities(),
|
'activities' => self::getActivities(),
|
||||||
'roles' => self::getRoles(),
|
'roles' => self::getRoles(),
|
||||||
'project_custom_fields' => self::getProjectCustomFields(),
|
'project_custom_fields' => self::getProjectCustomFields(),
|
||||||
|
'custom_fields' => self::getAllCustomFields(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
563
inc/templateengine.class.php
Normal file
563
inc/templateengine.class.php
Normal file
|
|
@ -0,0 +1,563 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linha do template da operação (ativa ou não), ou null.
|
||||||
|
*/
|
||||||
|
public static function getRow($operation) {
|
||||||
|
global $DB;
|
||||||
|
if (!$DB->tableExists('glpi_plugin_redmine_templates')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$it = $DB->request(['FROM' => 'glpi_plugin_redmine_templates', 'WHERE' => ['operation' => $operation]]);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Paleta (catálogo de variáveis para a UI)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catálogo de variáveis disponíveis, com a tag pronta para copiar.
|
||||||
|
* @return array de ['group','tag','desc']
|
||||||
|
*/
|
||||||
|
public static function variableCatalog() {
|
||||||
|
$cat = [];
|
||||||
|
$add = function ($group, $var, $desc) use (&$cat) {
|
||||||
|
$cat[] = ['group' => $group, 'tag' => '{{ ' . $var . ' }}', 'desc' => $desc];
|
||||||
|
};
|
||||||
|
$add('chamado', 'chamado.id', 'ID do chamado');
|
||||||
|
$add('chamado', 'chamado.titulo | truncar:255', 'Título (truncado p/ o limite do Redmine)');
|
||||||
|
$add('chamado', 'chamado.descricao | sem_html', 'Descrição sem HTML');
|
||||||
|
$add('chamado', 'chamado.status', 'Status do chamado (número GLPI)');
|
||||||
|
$add('chamado', 'chamado.entidade', 'ID da entidade do chamado');
|
||||||
|
$add('tarefa', 'tarefa.horas', 'Duração em horas decimais (1h30 = 1.5)');
|
||||||
|
$add('tarefa', 'tarefa.segundos', 'Duração em segundos');
|
||||||
|
$add('tarefa', 'tarefa.descricao', 'Descrição da tarefa (sem HTML)');
|
||||||
|
$add('tarefa', 'tarefa.comentario', 'Descrição ou fallback ("Tempo lançado via GLPI...")');
|
||||||
|
$add('tarefa', 'tarefa.data', 'Data da tarefa (AAAA-MM-DD)');
|
||||||
|
$add('vinculo', 'vinculo.projeto_redmine', 'ID do projeto Redmine vinculado');
|
||||||
|
$add('vinculo', 'vinculo.issue_redmine', 'ID da issue Redmine do chamado');
|
||||||
|
$add('vinculo', 'vinculo.categoria_padrao', 'Categoria padrão do vínculo');
|
||||||
|
$add('config', 'config.tracker_padrao', 'Tracker padrão (formconfig)');
|
||||||
|
$add('config', 'config.prioridade_padrao', 'Prioridade padrão (formconfig)');
|
||||||
|
$add('config', 'config.atividade_padrao', 'Atividade padrão (formconfig)');
|
||||||
|
$add('config', 'config.observadores', 'Observadores padrão (array de ids)');
|
||||||
|
$add('map', 'map.status[chamado.status]', 'Status Redmine mapeado do status do chamado');
|
||||||
|
$add('chamado', 'chamado.tecnico', 'Nome do técnico atribuído (texto; a AUTORIA real é automática via impersonation)');
|
||||||
|
$add('tarefa', 'tarefa.autor', 'Nome de quem lançou a tarefa (texto; a AUTORIA real é automática via impersonation)');
|
||||||
|
$add('form (criar projeto)', 'form.nome', 'Nome digitado no form');
|
||||||
|
$add('form (criar projeto)', 'form.identificador | identificador', 'Identificador normalizado');
|
||||||
|
$add('form (criar projeto)', 'form.descricao', 'Descrição do form');
|
||||||
|
$add('form (criar projeto)', 'form.publico', 'Público (true/false)');
|
||||||
|
$add('form (criar projeto)', 'form.subprojeto_de', 'Projeto pai escolhido');
|
||||||
|
$add('form (criar projeto)', 'form.trackers', 'Trackers selecionados (array)');
|
||||||
|
$add('form (criar projeto)', 'form.modulos', 'Módulos selecionados (array)');
|
||||||
|
$add('form (criar projeto)', 'form.cf_<id>', 'Custom field do form (ex.: form.cf_4)');
|
||||||
|
return $cat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dicionário "PARA" da operação: os campos que o Redmine espera/aceita,
|
||||||
|
* com obrigatoriedade e valores possíveis do ambiente (via metadata cache).
|
||||||
|
* Alimenta a tabela "Campos que o Redmine espera" de cada card do editor.
|
||||||
|
* @return array de ['path','desc','required','values'=>[]]
|
||||||
|
*/
|
||||||
|
public static function fieldDictionary($operation) {
|
||||||
|
global $DB;
|
||||||
|
$it = $DB->request(['FROM' => 'glpi_plugin_redmine_configs', 'WHERE' => ['id' => 1]]);
|
||||||
|
$config = count($it) > 0 ? $it->current() : [];
|
||||||
|
$meta = !empty($config['metadata_cache']) ? json_decode($config['metadata_cache'], true) : [];
|
||||||
|
|
||||||
|
$vals = function ($list) {
|
||||||
|
$out = [];
|
||||||
|
foreach (($list ?? []) as $e) {
|
||||||
|
if (isset($e['id'], $e['name'])) {
|
||||||
|
$out[] = $e['id'] . ' = ' . $e['name'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
};
|
||||||
|
$trackers = $vals($meta['trackers'] ?? []);
|
||||||
|
$statuses = $vals($meta['statuses'] ?? []);
|
||||||
|
$priorities = $vals($meta['priorities'] ?? []);
|
||||||
|
$activities = $vals($meta['activities'] ?? []);
|
||||||
|
|
||||||
|
$D = [];
|
||||||
|
$add = function ($path, $desc, $req = false, $values = []) use (&$D) {
|
||||||
|
$D[] = ['path' => $path, 'desc' => $desc, 'required' => $req, 'values' => $values];
|
||||||
|
};
|
||||||
|
|
||||||
|
switch ($operation) {
|
||||||
|
case 'create_issue':
|
||||||
|
$add('issue.subject', 'Título da tarefa (máx. 255)', true);
|
||||||
|
$add('issue.description', 'Descrição');
|
||||||
|
$add('issue.tracker_id', 'Tipo de tarefa', false, $trackers);
|
||||||
|
$add('issue.status_id', 'Situação', false, $statuses);
|
||||||
|
$add('issue.priority_id', 'Prioridade', false, $priorities);
|
||||||
|
$add('issue.category_id', 'Categoria (os IDs variam por projeto Redmine)');
|
||||||
|
$add('issue.assigned_to_id', 'Atribuído para (ID de usuário Redmine)');
|
||||||
|
$add('issue.fixed_version_id', 'Versão/marco (por projeto)');
|
||||||
|
$add('issue.parent_issue_id', 'Tarefa pai (subtarefa)');
|
||||||
|
$add('issue.estimated_hours', 'Horas estimadas (decimal)');
|
||||||
|
$add('issue.start_date', 'Data de início (AAAA-MM-DD)');
|
||||||
|
$add('issue.due_date', 'Data limite (AAAA-MM-DD)');
|
||||||
|
$add('issue.is_private', 'Privada (true/false)');
|
||||||
|
$add('issue.watcher_user_ids', 'Observadores (array de IDs)');
|
||||||
|
break;
|
||||||
|
case 'log_time':
|
||||||
|
$add('time_entry.hours', 'Horas (decimal — use {{ tarefa.horas }})', true);
|
||||||
|
$add('time_entry.comments', 'Comentário (obrigatório em alguns ambientes)');
|
||||||
|
$add('time_entry.activity_id', 'Atividade (habilitada no projeto de destino!)', false, $activities);
|
||||||
|
$add('time_entry.spent_on', 'Data do lançamento (AAAA-MM-DD)');
|
||||||
|
break;
|
||||||
|
case 'update_status':
|
||||||
|
$add('issue.status_id', 'Situação', false, $statuses);
|
||||||
|
$add('issue.notes', 'Comentário adicionado à issue na atualização');
|
||||||
|
$add('issue.assigned_to_id', 'Atribuído para (ID de usuário Redmine)');
|
||||||
|
$add('issue.done_ratio', '% concluído (0-100)');
|
||||||
|
break;
|
||||||
|
case 'create_project':
|
||||||
|
$add('project.name', 'Nome do projeto', true);
|
||||||
|
$add('project.identifier', 'Identificador (minúsculas — use o filtro | identificador)', true);
|
||||||
|
$add('project.description', 'Descrição');
|
||||||
|
$add('project.is_public', 'Público (true/false)');
|
||||||
|
$add('project.parent_id', 'ID do projeto pai (subprojeto de)');
|
||||||
|
$add('project.inherit_members', 'Herdar membros do pai (true/false)');
|
||||||
|
$add('project.tracker_ids', 'Tipos de tarefa habilitados (array)', false, $trackers);
|
||||||
|
$add('project.enabled_module_names', 'Módulos (array: issue_tracking, time_tracking, wiki...)');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom fields do ambiente, por tipo de objeto da operação
|
||||||
|
$type_by_op = ['create_project' => 'project', 'create_issue' => 'issue', 'log_time' => 'time_entry'];
|
||||||
|
$root_by_op = ['create_project' => 'project', 'create_issue' => 'issue', 'log_time' => 'time_entry'];
|
||||||
|
if (isset($type_by_op[$operation])) {
|
||||||
|
$cfs = $meta['custom_fields'] ?? $meta['project_custom_fields'] ?? [];
|
||||||
|
foreach ($cfs as $f) {
|
||||||
|
if (($f['customized_type'] ?? '') !== $type_by_op[$operation]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$pv = array_column($f['possible_values'] ?? [], 'value');
|
||||||
|
$add(
|
||||||
|
$root_by_op[$operation] . '.custom_field_values.' . (int) $f['id'],
|
||||||
|
$f['name'] . ' (campo personalizado do seu Redmine)',
|
||||||
|
!empty($f['is_required']),
|
||||||
|
$pv
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $D;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Contexto
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contexto para o create_project (aba Redmine de Projeto/Entidade):
|
||||||
|
* base (config/map) + namespace form.* a partir do POST do formulário.
|
||||||
|
*/
|
||||||
|
public static function buildFormContext(array $post) {
|
||||||
|
$ctx = self::buildContext();
|
||||||
|
$form = [
|
||||||
|
'nome' => (string) ($post['name'] ?? ''),
|
||||||
|
'identificador' => (string) ($post['identifier'] ?? ''),
|
||||||
|
'descricao' => (string) ($post['description'] ?? ''),
|
||||||
|
'publico' => !empty($post['is_public']),
|
||||||
|
'subprojeto_de' => !empty($post['parent_id']) ? (int) $post['parent_id'] : null,
|
||||||
|
'trackers' => !empty($post['tracker_ids']) && is_array($post['tracker_ids'])
|
||||||
|
? array_values(array_filter(array_map('intval', $post['tracker_ids']))) : null,
|
||||||
|
'modulos' => !empty($post['enabled_module_names']) && is_array($post['enabled_module_names'])
|
||||||
|
? array_values($post['enabled_module_names']) : null,
|
||||||
|
];
|
||||||
|
foreach (($post['custom_field_values'] ?? []) as $cfid => $v) {
|
||||||
|
if ($v !== '' && $v !== null && $v !== '0' && $v !== 0) {
|
||||||
|
$form['cf_' . (int) $cfid] = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$ctx['form'] = $form;
|
||||||
|
return $ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
global $DB;
|
||||||
|
$tecnico = null;
|
||||||
|
$itT = $DB->request([
|
||||||
|
'SELECT' => 'users_id',
|
||||||
|
'FROM' => 'glpi_tickets_users',
|
||||||
|
'WHERE' => ['tickets_id' => (int) $ticket->getID(), 'type' => \CommonITILActor::ASSIGN, ['users_id' => ['>', 0]]],
|
||||||
|
'LIMIT' => 1,
|
||||||
|
]);
|
||||||
|
if (count($itT) > 0) {
|
||||||
|
$tecnico = getUserName($itT->current()['users_id']);
|
||||||
|
}
|
||||||
|
$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'],
|
||||||
|
'tecnico' => $tecnico,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
'autor' => !empty($task->fields['users_id']) ? getUserName($task->fields['users_id']) : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contexto de EXEMPLO para o testador: config/mapa reais + chamado/tarefa
|
||||||
|
* fictícios + custom fields do form preenchidos com o 1º valor possível.
|
||||||
|
*/
|
||||||
|
public static function sampleContext($operation) {
|
||||||
|
global $DB;
|
||||||
|
$ctx = self::buildContext();
|
||||||
|
|
||||||
|
$link = $DB->request(['FROM' => 'glpi_plugin_redmine_projects', 'LIMIT' => 1]);
|
||||||
|
$rpid = count($link) > 0 ? (int) $link->current()['redmine_project_id'] : 1;
|
||||||
|
$ctx['vinculo']['projeto_redmine'] = $rpid;
|
||||||
|
$ctx['vinculo']['issue_redmine'] = 12345;
|
||||||
|
|
||||||
|
$ctx['chamado'] = [
|
||||||
|
'id' => 4321,
|
||||||
|
'titulo' => 'Exemplo: erro no servidor de e-mail',
|
||||||
|
'descricao' => '<p>Descrição de exemplo do chamado.</p>',
|
||||||
|
'status' => 2,
|
||||||
|
'entidade' => 0,
|
||||||
|
'tecnico' => 'Fulano Técnico (exemplo)',
|
||||||
|
];
|
||||||
|
$ctx['tarefa'] = [
|
||||||
|
'id' => 987,
|
||||||
|
'segundos' => 5400,
|
||||||
|
'horas' => 1.5,
|
||||||
|
'descricao' => 'Tarefa de exemplo concluída',
|
||||||
|
'comentario' => 'Tarefa de exemplo concluída',
|
||||||
|
'data' => date('Y-m-d'),
|
||||||
|
'autor' => 'Fulano Técnico (exemplo)',
|
||||||
|
];
|
||||||
|
|
||||||
|
$form = [
|
||||||
|
'nome' => 'Projeto Exemplo',
|
||||||
|
'identificador' => 'Projeto-Exemplo',
|
||||||
|
'descricao' => 'Descrição de exemplo',
|
||||||
|
'publico' => false,
|
||||||
|
'subprojeto_de' => null,
|
||||||
|
'trackers' => !empty($ctx['config']['tracker_padrao']) ? [$ctx['config']['tracker_padrao']] : null,
|
||||||
|
'modulos' => ['issue_tracking', 'time_tracking'],
|
||||||
|
];
|
||||||
|
// custom fields obrigatórios do form: usa o 1º valor possível (teste passável)
|
||||||
|
$it = $DB->request(['FROM' => 'glpi_plugin_redmine_configs', 'WHERE' => ['id' => 1]]);
|
||||||
|
$config = count($it) > 0 ? $it->current() : [];
|
||||||
|
$meta = !empty($config['metadata_cache']) ? json_decode($config['metadata_cache'], true) : [];
|
||||||
|
foreach (($meta['project_custom_fields'] ?? []) as $f) {
|
||||||
|
$pv = array_column($f['possible_values'] ?? [], 'value');
|
||||||
|
$form['cf_' . (int) $f['id']] = $pv[0] ?? 'exemplo';
|
||||||
|
}
|
||||||
|
$ctx['form'] = $form;
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the Mindplace License class if the autoloader has not run yet.
|
* Load the Mindplace License class if the autoloader has not run yet.
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,138 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ====================================================================
|
||||||
|
Templates de Payload (motor 2.0) — editor linha-a-linha + paleta
|
||||||
|
==================================================================== #}
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4 class="card-title mb-0"><i class="ti ti-code me-1"></i>{{ __('Templates de Payload (avançado)', 'redmine') }}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
{{ __('Personalize o JSON enviado ao Redmine em cada operação. Sem template ativo, o plugin usa o comportamento padrão. "Gerar template" cria o ponto de partida equivalente ao padrão + campos obrigatórios descobertos no seu Redmine.', 'redmine') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="rm-tpl-meta" class="d-none" data-testurl="{{ ajax_test_url }}"></div>
|
||||||
|
|
||||||
|
{# Variáveis do GLPI como AUTOCOMPLETE nos campos de valor #}
|
||||||
|
<datalist id="rm-glpi-vars">
|
||||||
|
{% for v in palette_vars %}
|
||||||
|
<option value="{{ v.tag }}">{{ v.group }} — {{ v.desc }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</datalist>
|
||||||
|
|
||||||
|
{# Referência opcional (colapsada) — descoberta das tags para remapeamentos #}
|
||||||
|
<div class="mb-3">
|
||||||
|
<a class="small" data-bs-toggle="collapse" href="#rm-glpi-vars-panel" role="button">
|
||||||
|
<i class="ti ti-variable me-1"></i>{{ __('Variáveis do GLPI disponíveis — o "DE" (referência; nos campos de valor há autocomplete)', 'redmine') }}
|
||||||
|
</a>
|
||||||
|
<div class="collapse" id="rm-glpi-vars-panel">
|
||||||
|
<div class="card card-body p-2 mt-1" style="max-height:300px;overflow-y:auto;columns:2">
|
||||||
|
{% set lastgroup = '' %}
|
||||||
|
{% for v in palette_vars %}
|
||||||
|
{% if v.group != lastgroup %}
|
||||||
|
{% set lastgroup = v.group %}
|
||||||
|
<div class="mt-1 mb-1 text-uppercase text-muted" style="font-size:.7rem;break-inside:avoid">{{ v.group }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="rm-var px-1 rounded" data-tag="{{ v.tag }}" title="{{ v.desc }}" style="break-inside:avoid">
|
||||||
|
<code style="font-size:.75rem">{{ v.tag }}</code>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ---- Editores por operação (largura total) ---- #}
|
||||||
|
<div>
|
||||||
|
{% for op, t in templates %}
|
||||||
|
<div class="card mb-3 rm-tpl-card" data-op="{{ op }}">
|
||||||
|
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||||
|
<span>
|
||||||
|
<strong>{{ t.label }}</strong>
|
||||||
|
<code class="ms-1" style="font-size:.7rem">{{ op }}</code>
|
||||||
|
{% if t.is_active %}
|
||||||
|
<span class="badge bg-green-lt text-green ms-1">{{ __('ativo', 'redmine') }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary-lt ms-1">{{ __('inativo (usa o padrão)', 'redmine') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary rm-tpl-toggle">{{ __('Ver JSON', 'redmine') }}</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary rm-tpl-gen" data-seed="{{ t.seed }}"
|
||||||
|
data-confirm="{{ __('Preencher o editor com o template padrão (+ campos obrigatórios do seu Redmine)? O conteúdo atual do editor será substituído — nada é salvo até você clicar em Salvar.', 'redmine') }}">
|
||||||
|
<i class="ti ti-wand me-1"></i>{{ __('Gerar template', 'redmine') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-success rm-tpl-test">
|
||||||
|
<i class="ti ti-flask me-1"></i>{{ __('Testar', 'redmine') }}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if field_dicts[op] is defined and field_dicts[op] is not empty %}
|
||||||
|
<div class="px-3 py-1 border-bottom">
|
||||||
|
<a class="small" data-bs-toggle="collapse" href="#rmdict-{{ op }}" role="button">
|
||||||
|
<i class="ti ti-book me-1"></i>{{ __('Campos que o Redmine espera — o "PARA" (clique num campo para adicioná-lo)', 'redmine') }}
|
||||||
|
</a>
|
||||||
|
<div class="collapse" id="rmdict-{{ op }}">
|
||||||
|
<table class="table table-sm table-hover mb-1 mt-1" style="font-size:.75rem">
|
||||||
|
<tbody>
|
||||||
|
{% for f in field_dicts[op] %}
|
||||||
|
<tr class="rm-dict-row" data-path="{{ f.path }}" title="{{ __('Clique para adicionar este campo ao template', 'redmine') }}">
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
<code>{{ f.path }}</code>
|
||||||
|
{% if f.required %}<span class="badge bg-red-lt text-red ms-1">{{ __('obrigatório', 'redmine') }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ f.desc }}
|
||||||
|
{% if f.values is not empty %}
|
||||||
|
<div class="text-muted" style="font-size:.7rem">{{ f.values|join(' · ') }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="rm-tpl-lines"></div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary rm-tpl-add mt-1">
|
||||||
|
<i class="ti ti-plus me-1"></i>{{ __('Adicionar campo', 'redmine') }}
|
||||||
|
</button>
|
||||||
|
<textarea name="templates_json[{{ op }}]" class="form-control rm-tpl-json d-none mt-1" rows="10"
|
||||||
|
spellcheck="false" style="font-family: monospace; font-size:.8rem">{{ t.template }}</textarea>
|
||||||
|
|
||||||
|
{# Painel do testador (Fase 3): preview + teste real opcional #}
|
||||||
|
<div class="rm-tpl-testpanel d-none border-top mt-2 pt-2">
|
||||||
|
<div class="d-flex gap-2 align-items-center mb-2 flex-wrap">
|
||||||
|
{% if op != 'create_project' %}
|
||||||
|
<select class="form-select form-select-sm rm-test-target" style="max-width:320px">
|
||||||
|
<option value="">{{ __('Projeto Redmine alvo para o teste real...', 'redmine') }}</option>
|
||||||
|
{% for pid, pname in test_project_options %}
|
||||||
|
<option value="{{ pid }}">{{ pname }} (#{{ pid }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-warning rm-test-run">
|
||||||
|
<i class="ti ti-player-play me-1"></i>{{ __('Teste real (cria e apaga um objeto de teste no Redmine)', 'redmine') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="rm-test-out p-2 rounded border" style="font-size:.75rem;max-height:320px;overflow:auto;white-space:pre-wrap"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer py-2">
|
||||||
|
<label class="form-check m-0">
|
||||||
|
<input type="checkbox" class="form-check-input" name="templates_active[{{ op }}]" value="1" {{ t.is_active ? 'checked' : '' }}>
|
||||||
|
<span class="form-check-label">{{ __('Ativo (salvo pelo botão Salvar no fim da página)', 'redmine') }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="d-flex justify-content-end mb-3">
|
<div class="d-flex justify-content-end mb-3">
|
||||||
<button type="submit" name="update" value="1" class="btn btn-primary">
|
<button type="submit" name="update" value="1" class="btn btn-primary">
|
||||||
<i class="ti ti-device-floppy me-1"></i>{{ _x('button', 'Save') }}
|
<i class="ti ti-device-floppy me-1"></i>{{ _x('button', 'Save') }}
|
||||||
|
|
@ -140,3 +272,201 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% verbatim %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// ---- Paleta: clique copia a tag ----
|
||||||
|
document.querySelectorAll('.rm-var').forEach(function (el) {
|
||||||
|
el.style.cursor = 'pointer';
|
||||||
|
el.addEventListener('click', function () {
|
||||||
|
var tag = el.dataset.tag;
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(tag);
|
||||||
|
} else {
|
||||||
|
window.prompt('Copie a tag:', tag);
|
||||||
|
}
|
||||||
|
el.classList.add('bg-green-lt');
|
||||||
|
setTimeout(function () { el.classList.remove('bg-green-lt'); }, 500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Editor linha-a-linha <-> JSON ----
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
||||||
|
}
|
||||||
|
function flatten(obj, prefix, out) {
|
||||||
|
for (var k in obj) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(obj, k)) continue;
|
||||||
|
var v = obj[k];
|
||||||
|
var p = prefix ? prefix + '.' + k : k;
|
||||||
|
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
||||||
|
flatten(v, p, out);
|
||||||
|
} else {
|
||||||
|
out.push([p, (typeof v === 'string') ? v : JSON.stringify(v)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function parseVal(s) {
|
||||||
|
s = String(s).trim();
|
||||||
|
if (s === '') return '';
|
||||||
|
try {
|
||||||
|
var v = JSON.parse(s);
|
||||||
|
if (typeof v === 'number' || typeof v === 'boolean' || Array.isArray(v) || v === null) return v;
|
||||||
|
} catch (e) { /* string */ }
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
function unflatten(rows) {
|
||||||
|
var root = {};
|
||||||
|
rows.forEach(function (r) {
|
||||||
|
var p = r[0], val = r[1];
|
||||||
|
if (!p) return;
|
||||||
|
var parts = p.split('.');
|
||||||
|
var cur = root;
|
||||||
|
parts.forEach(function (seg, i) {
|
||||||
|
if (i === parts.length - 1) {
|
||||||
|
cur[seg] = parseVal(val);
|
||||||
|
} else {
|
||||||
|
if (typeof cur[seg] !== 'object' || cur[seg] === null || Array.isArray(cur[seg])) cur[seg] = {};
|
||||||
|
cur = cur[seg];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.rm-tpl-card').forEach(function (card) {
|
||||||
|
var ta = card.querySelector('.rm-tpl-json');
|
||||||
|
var linesBox = card.querySelector('.rm-tpl-lines');
|
||||||
|
var toggle = card.querySelector('.rm-tpl-toggle');
|
||||||
|
var addBtn = card.querySelector('.rm-tpl-add');
|
||||||
|
var form = card.closest('form');
|
||||||
|
var rawMode = false;
|
||||||
|
|
||||||
|
function addRow(p, v) {
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'd-flex gap-2 mb-1 rm-tpl-row';
|
||||||
|
row.innerHTML =
|
||||||
|
'<input class="form-control form-control-sm rm-k" style="max-width:42%;font-family:monospace;font-size:.78rem" placeholder="caminho.do.campo" value="' + esc(p) + '">' +
|
||||||
|
'<input class="form-control form-control-sm rm-v" list="rm-glpi-vars" style="font-family:monospace;font-size:.78rem" placeholder="valor fixo ou variável do GLPI (autocomplete)" value="' + esc(v) + '">' +
|
||||||
|
'<button type="button" class="btn btn-sm btn-outline-danger rm-del">×</button>';
|
||||||
|
row.querySelector('.rm-del').addEventListener('click', function () { row.remove(); });
|
||||||
|
linesBox.appendChild(row);
|
||||||
|
}
|
||||||
|
function renderLines() {
|
||||||
|
linesBox.innerHTML = '';
|
||||||
|
var data = {};
|
||||||
|
var raw = ta.value.trim();
|
||||||
|
if (raw !== '') {
|
||||||
|
try { data = JSON.parse(raw); } catch (e) { rawMode = true; setMode(); return; }
|
||||||
|
}
|
||||||
|
flatten(data, '', []).forEach(function (r) { addRow(r[0], r[1]); });
|
||||||
|
if (!linesBox.children.length) addRow('', '');
|
||||||
|
}
|
||||||
|
function syncToTextarea() {
|
||||||
|
if (rawMode) return;
|
||||||
|
var rows = Array.prototype.map.call(linesBox.querySelectorAll('.rm-tpl-row'), function (r) {
|
||||||
|
return [r.querySelector('.rm-k').value.trim(), r.querySelector('.rm-v').value];
|
||||||
|
}).filter(function (r) { return r[0] !== ''; });
|
||||||
|
ta.value = rows.length ? JSON.stringify(unflatten(rows), null, 2) : '';
|
||||||
|
}
|
||||||
|
function setMode() {
|
||||||
|
ta.classList.toggle('d-none', !rawMode);
|
||||||
|
linesBox.classList.toggle('d-none', rawMode);
|
||||||
|
addBtn.classList.toggle('d-none', rawMode);
|
||||||
|
toggle.textContent = rawMode ? 'Ver campos' : 'Ver JSON';
|
||||||
|
}
|
||||||
|
toggle.addEventListener('click', function () {
|
||||||
|
if (rawMode) {
|
||||||
|
var raw = ta.value.trim();
|
||||||
|
if (raw !== '') {
|
||||||
|
try { JSON.parse(raw); } catch (e) { alert('JSON inválido — corrija antes de voltar para os campos.'); return; }
|
||||||
|
}
|
||||||
|
rawMode = false;
|
||||||
|
renderLines();
|
||||||
|
} else {
|
||||||
|
syncToTextarea();
|
||||||
|
rawMode = true;
|
||||||
|
}
|
||||||
|
setMode();
|
||||||
|
});
|
||||||
|
addBtn.addEventListener('click', function () { addRow('', ''); });
|
||||||
|
// Gerar template: preenche o editor localmente (nada é salvo até o Salvar único)
|
||||||
|
var genBtn = card.querySelector('.rm-tpl-gen');
|
||||||
|
if (genBtn) {
|
||||||
|
genBtn.addEventListener('click', function () {
|
||||||
|
if (!confirm(genBtn.dataset.confirm || 'Substituir o conteúdo do editor pelo template padrão?')) { return; }
|
||||||
|
try {
|
||||||
|
ta.value = JSON.stringify(JSON.parse(genBtn.dataset.seed || '{}'), null, 2);
|
||||||
|
} catch (e) {
|
||||||
|
ta.value = genBtn.dataset.seed || '';
|
||||||
|
}
|
||||||
|
rawMode = false;
|
||||||
|
renderLines();
|
||||||
|
setMode();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Testador (Fase 3): preview do render + teste real opcional
|
||||||
|
var testBtn = card.querySelector('.rm-tpl-test');
|
||||||
|
var panel = card.querySelector('.rm-tpl-testpanel');
|
||||||
|
var out = card.querySelector('.rm-test-out');
|
||||||
|
var runBtn = card.querySelector('.rm-test-run');
|
||||||
|
var meta = document.getElementById('rm-tpl-meta');
|
||||||
|
function csrfToken() {
|
||||||
|
var m = document.querySelector('meta[property="glpi:csrf_token"]');
|
||||||
|
return m ? m.getAttribute('content') : '';
|
||||||
|
}
|
||||||
|
function callTest(action) {
|
||||||
|
syncToTextarea();
|
||||||
|
var body = new URLSearchParams({
|
||||||
|
action: action,
|
||||||
|
operation: card.dataset.op || '',
|
||||||
|
template_json: ta.value
|
||||||
|
});
|
||||||
|
var tgt = card.querySelector('.rm-test-target');
|
||||||
|
if (tgt && tgt.value) { body.append('target_project', tgt.value); }
|
||||||
|
panel.classList.remove('d-none');
|
||||||
|
out.textContent = '... executando (' + action + ') ...';
|
||||||
|
fetch(meta.dataset.testurl, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'X-Glpi-Csrf-Token': csrfToken()
|
||||||
|
},
|
||||||
|
body: body.toString()
|
||||||
|
}).then(function (r) { return r.json(); }).then(function (d) {
|
||||||
|
var txt = '';
|
||||||
|
if (d.note) { txt += d.note + '\n\n'; }
|
||||||
|
if (d.message) { txt += d.message + '\n'; }
|
||||||
|
if (d.errors && d.errors.length) { txt += '\nDicas:\n - ' + d.errors.join('\n - ') + '\n'; }
|
||||||
|
if (d.payload) { txt += JSON.stringify(d.payload, null, 2); }
|
||||||
|
if (d.sent) { txt += '\n\nPayload enviado:\n' + JSON.stringify(d.sent, null, 2); }
|
||||||
|
out.textContent = (d.success ? '✅ ' : '❌ ') + txt;
|
||||||
|
}).catch(function (e) {
|
||||||
|
out.textContent = '❌ Falha na chamada de teste: ' + e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (testBtn) { testBtn.addEventListener('click', function () { callTest('preview'); }); }
|
||||||
|
if (runBtn) { runBtn.addEventListener('click', function () { callTest('dryrun'); }); }
|
||||||
|
|
||||||
|
// Dicionário "PARA": clique num campo adiciona a linha com o caminho pronto
|
||||||
|
card.querySelectorAll('.rm-dict-row').forEach(function (r) {
|
||||||
|
r.style.cursor = 'pointer';
|
||||||
|
r.addEventListener('click', function () {
|
||||||
|
if (rawMode) { alert('Volte para "Ver campos" para adicionar pela lista.'); return; }
|
||||||
|
addRow(r.dataset.path, '');
|
||||||
|
var rows = linesBox.querySelectorAll('.rm-tpl-row');
|
||||||
|
var last = rows[rows.length - 1];
|
||||||
|
if (last) { last.querySelector('.rm-v').focus(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
form.addEventListener('submit', function () { syncToTextarea(); });
|
||||||
|
renderLines();
|
||||||
|
setMode();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endverbatim %}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue