feat: manage script mappings from admin
This commit is contained in:
parent
c39a24a4f2
commit
4e2f181dd9
7 changed files with 325 additions and 2 deletions
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Repository\ScriptMappingRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
|
@ -9,8 +10,10 @@ use Symfony\Component\Routing\Attribute\Route;
|
|||
final class DashboardController extends AbstractController
|
||||
{
|
||||
#[Route('/admin', name: 'admin_dashboard', methods: ['GET'])]
|
||||
public function __invoke(): Response
|
||||
public function __invoke(ScriptMappingRepository $mappingRepository): Response
|
||||
{
|
||||
return $this->render('admin/dashboard.html.twig');
|
||||
return $this->render('admin/dashboard.html.twig', [
|
||||
'mappings' => $mappingRepository->findAllOrderedByPublicPath(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
86
src/Controller/Admin/ScriptMappingController.php
Normal file
86
src/Controller/Admin/ScriptMappingController.php
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Entity\ScriptMapping;
|
||||
use App\Form\ScriptMappingType;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/admin/mappings')]
|
||||
final class ScriptMappingController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/new', name: 'admin_mapping_new', methods: ['GET', 'POST'])]
|
||||
public function new(Request $request): Response
|
||||
{
|
||||
$mapping = new ScriptMapping();
|
||||
$form = $this->createForm(ScriptMappingType::class, $mapping);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$token = (string) $form->get('accessToken')->getData();
|
||||
$mapping->setAccessToken($token);
|
||||
|
||||
$this->entityManager->persist($mapping);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Mapping créé.');
|
||||
|
||||
return $this->redirectToRoute('admin_dashboard');
|
||||
}
|
||||
|
||||
return $this->render('admin/mapping_form.html.twig', [
|
||||
'form' => $form,
|
||||
'mapping' => $mapping,
|
||||
'title' => 'Nouveau mapping',
|
||||
], new Response(status: $form->isSubmitted() ? Response::HTTP_UNPROCESSABLE_ENTITY : Response::HTTP_OK));
|
||||
}
|
||||
|
||||
#[Route('/{id}/edit', name: 'admin_mapping_edit', methods: ['GET', 'POST'])]
|
||||
public function edit(Request $request, ScriptMapping $mapping): Response
|
||||
{
|
||||
$form = $this->createForm(ScriptMappingType::class, $mapping);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$token = (string) $form->get('accessToken')->getData();
|
||||
if ($token !== '') {
|
||||
$mapping->setAccessToken($token);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Mapping mis à jour.');
|
||||
|
||||
return $this->redirectToRoute('admin_dashboard');
|
||||
}
|
||||
|
||||
return $this->render('admin/mapping_form.html.twig', [
|
||||
'form' => $form,
|
||||
'mapping' => $mapping,
|
||||
'title' => 'Modifier le mapping',
|
||||
], new Response(status: $form->isSubmitted() ? Response::HTTP_UNPROCESSABLE_ENTITY : Response::HTTP_OK));
|
||||
}
|
||||
|
||||
#[Route('/{id}/delete', name: 'admin_mapping_delete', methods: ['POST'])]
|
||||
public function delete(Request $request, ScriptMapping $mapping): Response
|
||||
{
|
||||
if (!$this->isCsrfTokenValid('delete_mapping_'.$mapping->getId(), (string) $request->request->get('_token'))) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
|
||||
$this->entityManager->remove($mapping);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Mapping supprimé.');
|
||||
|
||||
return $this->redirectToRoute('admin_dashboard');
|
||||
}
|
||||
}
|
||||
69
src/Form/ScriptMappingType.php
Normal file
69
src/Form/ScriptMappingType.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity\ScriptMapping;
|
||||
use App\Service\PathNormalizer;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\UrlType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
final class ScriptMappingType extends AbstractType
|
||||
{
|
||||
public function __construct(private readonly PathNormalizer $pathNormalizer)
|
||||
{
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('publicPath', TextType::class)
|
||||
->add('repositoryUrl', UrlType::class, [
|
||||
'default_protocol' => null,
|
||||
])
|
||||
->add('gitRef', TextType::class)
|
||||
->add('repositoryFilePath', TextType::class)
|
||||
->add('accessToken', PasswordType::class, [
|
||||
'mapped' => false,
|
||||
'required' => false,
|
||||
])
|
||||
->add('active', CheckboxType::class, [
|
||||
'required' => false,
|
||||
])
|
||||
->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event): void {
|
||||
$mapping = $event->getData();
|
||||
if (!$mapping instanceof ScriptMapping) {
|
||||
return;
|
||||
}
|
||||
|
||||
$form = $event->getForm();
|
||||
|
||||
try {
|
||||
$mapping->setPublicPath($this->pathNormalizer->publicPath($mapping->getPublicPath()));
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
$form->get('publicPath')->addError(new FormError($exception->getMessage()));
|
||||
}
|
||||
|
||||
try {
|
||||
$mapping->setRepositoryFilePath($this->pathNormalizer->repositoryPath($mapping->getRepositoryFilePath()));
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
$form->get('repositoryFilePath')->addError(new FormError($exception->getMessage()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => ScriptMapping::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -20,4 +20,10 @@ final class ScriptMappingRepository extends ServiceEntityRepository
|
|||
'active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<ScriptMapping> */
|
||||
public function findAllOrderedByPublicPath(): array
|
||||
{
|
||||
return $this->findBy([], ['publicPath' => 'ASC']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,5 +5,51 @@
|
|||
{% block body %}
|
||||
<main>
|
||||
<h1>Mappings</h1>
|
||||
<p><a href="{{ path('admin_mapping_new') }}">Nouveau mapping</a></p>
|
||||
|
||||
{% for message in app.flashes('success') %}
|
||||
<p>{{ message }}</p>
|
||||
{% endfor %}
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Chemin public</th>
|
||||
<th>Dépôt</th>
|
||||
<th>Réf.</th>
|
||||
<th>Fichier</th>
|
||||
<th>Actif</th>
|
||||
<th>Dernière synchro</th>
|
||||
<th>Dernier succès</th>
|
||||
<th>Erreur</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for mapping in mappings %}
|
||||
<tr>
|
||||
<td>{{ mapping.publicPath }}</td>
|
||||
<td>{{ mapping.repositoryUrl }}</td>
|
||||
<td>{{ mapping.gitRef }}</td>
|
||||
<td>{{ mapping.repositoryFilePath }}</td>
|
||||
<td>{{ mapping.active ? 'oui' : 'non' }}</td>
|
||||
<td>{{ mapping.lastSyncStatus }}</td>
|
||||
<td>{{ mapping.lastSuccessfulSyncAt ? mapping.lastSuccessfulSyncAt|date('Y-m-d H:i:s') : '' }}</td>
|
||||
<td>{{ mapping.lastSyncError }}</td>
|
||||
<td>
|
||||
<a href="{{ path('admin_mapping_edit', {id: mapping.id}) }}">Modifier</a>
|
||||
<form method="post" action="{{ path('admin_mapping_delete', {id: mapping.id}) }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete_mapping_' ~ mapping.id) }}">
|
||||
<button type="submit">Supprimer</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="9">Aucun mapping.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
|
|||
19
templates/admin/mapping_form.html.twig
Normal file
19
templates/admin/mapping_form.html.twig
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<main>
|
||||
<h1>{{ title }}</h1>
|
||||
|
||||
{{ form_start(form) }}
|
||||
{{ form_row(form.publicPath) }}
|
||||
{{ form_row(form.repositoryUrl) }}
|
||||
{{ form_row(form.gitRef) }}
|
||||
{{ form_row(form.repositoryFilePath) }}
|
||||
{{ form_row(form.accessToken) }}
|
||||
{{ form_row(form.active) }}
|
||||
<button type="submit">Enregistrer</button>
|
||||
{{ form_end(form) }}
|
||||
</main>
|
||||
{% endblock %}
|
||||
94
tests/Controller/Admin/ScriptMappingControllerTest.php
Normal file
94
tests/Controller/Admin/ScriptMappingControllerTest.php
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
namespace App\Tests\Controller\Admin;
|
||||
|
||||
use App\Entity\ScriptMapping;
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Tools\SchemaTool;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
final class ScriptMappingControllerTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::ensureKernelShutdown();
|
||||
$this->client = self::createClient();
|
||||
|
||||
$this->entityManager = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$metadata = $this->entityManager->getMetadataFactory()->getAllMetadata();
|
||||
$schemaTool = new SchemaTool($this->entityManager);
|
||||
$schemaTool->dropSchema($metadata);
|
||||
$schemaTool->createSchema($metadata);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
parent::tearDown();
|
||||
|
||||
$this->entityManager->close();
|
||||
}
|
||||
|
||||
public function testNewMappingRequiresAuthentication(): void
|
||||
{
|
||||
$this->client->request('GET', '/admin/mappings/new');
|
||||
|
||||
self::assertResponseRedirects('/admin/login');
|
||||
}
|
||||
|
||||
public function testAdminCanCreateMappingWithNormalizedPaths(): void
|
||||
{
|
||||
$this->loginAsAdmin();
|
||||
|
||||
$crawler = $this->client->request('GET', '/admin/mappings/new');
|
||||
$form = $crawler->selectButton('Enregistrer')->form([
|
||||
'script_mapping[publicPath]' => '/mcp/graylog/install.sh',
|
||||
'script_mapping[repositoryUrl]' => 'https://forge.lclr.dev/AI/graylog-mcp.git',
|
||||
'script_mapping[gitRef]' => 'main',
|
||||
'script_mapping[repositoryFilePath]' => '/install.sh',
|
||||
'script_mapping[active]' => '1',
|
||||
]);
|
||||
|
||||
$this->client->submit($form);
|
||||
|
||||
self::assertResponseRedirects('/admin');
|
||||
|
||||
$mapping = $this->entityManager->getRepository(ScriptMapping::class)->findOneBy([
|
||||
'publicPath' => 'mcp/graylog/install.sh',
|
||||
]);
|
||||
self::assertInstanceOf(ScriptMapping::class, $mapping);
|
||||
self::assertSame('install.sh', $mapping->getRepositoryFilePath());
|
||||
}
|
||||
|
||||
public function testInvalidPublicPathRendersValidationError(): void
|
||||
{
|
||||
$this->loginAsAdmin();
|
||||
|
||||
$crawler = $this->client->request('GET', '/admin/mappings/new');
|
||||
$form = $crawler->selectButton('Enregistrer')->form([
|
||||
'script_mapping[publicPath]' => 'mcp/graylog/install.txt',
|
||||
'script_mapping[repositoryUrl]' => 'https://forge.lclr.dev/AI/graylog-mcp.git',
|
||||
'script_mapping[gitRef]' => 'main',
|
||||
'script_mapping[repositoryFilePath]' => 'install.sh',
|
||||
'script_mapping[active]' => '1',
|
||||
]);
|
||||
|
||||
$this->client->submit($form);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertSelectorTextContains('form', 'Public path must end with .sh.');
|
||||
}
|
||||
|
||||
private function loginAsAdmin(): void
|
||||
{
|
||||
$user = (new User())->setUsername('admin')->setPasswordHash('unused');
|
||||
$this->entityManager->persist($user);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->client->loginUser($user);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue