From 7bf8cdab4e607a223cbe979a02f314ef91adaed2 Mon Sep 17 00:00:00 2001 From: Gemini Date: Fri, 3 Jul 2026 08:58:17 -0300 Subject: [PATCH] =?UTF-8?q?feat(2.0):=20Fase=203=20=E2=80=94=20testador=20?= =?UTF-8?q?de=20templates=20(preview=20+=20dry-run=20com=20tradu=C3=A7?= =?UTF-8?q?=C3=A3o=20de=20422)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 5 ++ ajax/template_test.php | 155 ++++++++++++++++++++++++++++++++ docs/DESIGN-2.0.md | 2 +- inc/config.class.php | 3 + inc/redmineapi.class.php | 30 +++++++ inc/templateengine.class.php | 53 +++++++++++ templates/config_form.html.twig | 70 ++++++++++++++- 7 files changed, 316 insertions(+), 2 deletions(-) create mode 100644 ajax/template_test.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 417d675..0948f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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_` 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] diff --git a/ajax/template_test.php b/ajax/template_test.php new file mode 100644 index 0000000..3bfa14c --- /dev/null +++ b/ajax/template_test.php @@ -0,0 +1,155 @@ + 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); diff --git a/docs/DESIGN-2.0.md b/docs/DESIGN-2.0.md index 288c8a2..2be9efd 100644 --- a/docs/DESIGN-2.0.md +++ b/docs/DESIGN-2.0.md @@ -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). diff --git a/inc/config.class.php b/inc/config.class.php index c27cf1b..0b41166 100644 --- a/inc/config.class.php +++ b/inc/config.class.php @@ -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'] ?? '', diff --git a/inc/redmineapi.class.php b/inc/redmineapi.class.php index b0e45c6..67cf1cb 100644 --- a/inc/redmineapi.class.php +++ b/inc/redmineapi.class.php @@ -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 diff --git a/inc/templateengine.class.php b/inc/templateengine.class.php index 1c04a67..4840b08 100644 --- a/inc/templateengine.class.php +++ b/inc/templateengine.class.php @@ -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' => '

Descrição de exemplo do chamado.

', + '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 // ------------------------------------------------------------------ diff --git a/templates/config_form.html.twig b/templates/config_form.html.twig index 1352a8d..5e6cac4 100644 --- a/templates/config_form.html.twig +++ b/templates/config_form.html.twig @@ -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') }} +
+ {# Variáveis do GLPI como AUTOCOMPLETE nos campos de valor #} {% for v in palette_vars %} @@ -121,7 +123,7 @@ {# ---- Editores por operação (largura total) ---- #}
{% for op, t in templates %} -
+
{{ t.label }} @@ -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') }}"> {{ __('Gerar template', 'redmine') }} +
{% if field_dicts[op] is defined and field_dicts[op] is not empty %} @@ -174,6 +179,24 @@ + + {# Painel do testador (Fase 3): preview + teste real opcional #} +
+
+ {% if op != 'create_project' %} + + {% endif %} + +
+

+