feat(2.0): Fase 3 — testador de templates (preview + dry-run com tradução de 422)
- ajax/template_test.php: action=preview (render com sampleContext) e action=dryrun (cria+apaga objeto de teste no Redmine, traduz erros 422 em dicas acionáveis; issue temporária para log_time/update_status) - RedmineApi::rawRequest (código+corpo crus) e TemplateEngine::sampleContext - UI: botão "Testar" + painel por card (preview sempre; teste real opt-in com seletor de projeto alvo), via fetch com X-Glpi-Csrf-Token - validado E2E por HTTP: preview, dry-run cria/deleta (204), render inválido capturado, tradução de 422; sem resíduo de teste no Redmine Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
2c79d74545
commit
7bf8cdab4e
7 changed files with 316 additions and 2 deletions
|
|
@ -20,6 +20,11 @@ e o versionamento segue [SemVer](https://semver.org/lang/pt-BR/).
|
|||
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]
|
||||
|
||||
|
|
|
|||
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);
|
||||
|
|
@ -87,7 +87,7 @@ a escolha do admin e evita duplicação).
|
|||
operações do hook (`create_issue`, `log_time`, `update_status`). Invisível na UI.
|
||||
2. **UI:** editor linha-a-linha + paleta + "Gerar template" + wiring do `create_project`
|
||||
(contexto `form.*`).
|
||||
3. **Validação:** preview de render + dry-run opcional com tradução de 422.
|
||||
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).
|
||||
|
||||
|
|
|
|||
|
|
@ -225,10 +225,13 @@ class PluginRedmineConfig extends CommonDBTM {
|
|||
$field_dicts[$op] = PluginRedmineTemplateengine::fieldDictionary($op);
|
||||
}
|
||||
|
||||
global $CFG_GLPI;
|
||||
\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__),
|
||||
'csrf_token_value' => Session::getNewCSRFToken(),
|
||||
'redmine_url' => $config['redmine_url'] ?? '',
|
||||
|
|
|
|||
|
|
@ -344,6 +344,36 @@ class PluginRedmineRedmineapi {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -376,6 +376,59 @@ class PluginRedmineTemplateengine {
|
|||
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
|
||||
// ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -90,6 +90,8 @@
|
|||
{{ __('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 %}
|
||||
|
|
@ -121,7 +123,7 @@
|
|||
{# ---- Editores por operação (largura total) ---- #}
|
||||
<div>
|
||||
{% for op, t in templates %}
|
||||
<div class="card mb-3 rm-tpl-card">
|
||||
<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>
|
||||
|
|
@ -138,6 +140,9 @@
|
|||
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 %}
|
||||
|
|
@ -174,6 +179,24 @@
|
|||
</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">
|
||||
|
|
@ -384,6 +407,51 @@
|
|||
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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue