Browse Source

Gestion du workflow avec voters

Sangfroid 5 months ago
parent
commit
a05482cc8b

+ 1 - 1
config/packages/security.yaml

@@ -38,7 +38,7 @@ security:
         # - { path: ^/profile, roles: ROLE_USER }
 
     role_hierarchy:
-        ROLE_ADMIN: ROLE_AUTHOR
+        ROLE_ADMIN: ROLE_AUTHOR, ROLE_MODERATOR
 
 when@test:
     security:

+ 2 - 2
config/packages/translation.yaml

@@ -1,7 +1,7 @@
 framework:
-    default_locale: en
+    default_locale: fr
     translator:
         default_path: '%kernel.project_dir%/translations'
         fallbacks:
-            - en
+            - fr
         providers:

+ 6 - 6
config/packages/workflow.yaml

@@ -1,12 +1,12 @@
 framework:
     workflows:
         blog_publishing:
-            type: 'workflow' # or 'state_machine'
+            type: 'state_machine' # or 'state_machine'
             audit_trail:
                 enabled: true
             marking_store:
                 type: 'method'
-                property: 'currentPlace'
+                property: 'state'
             supports:
                 - App\Entity\Article
             initial_marking: draft
@@ -17,19 +17,19 @@ framework:
                 - published
             transitions:
                 to_review:
-                    guard: is_granted('edit, 'article')
+                    guard: is_granted('edit', subject)
                     from: rejected
                     to:   reviewed
                 publish:
-                    guard: is_granted('edit', 'article')
-                    from: [reviewed, draft]
+                    guard: is_granted('publish', subject)
+                    from: [reviewed,draft]
                     to:   published
                 reject:
                     guard: is_granted('ROLE_MODERATOR')
                     from: [reviewed, published]
                     to:   rejected
                 to_draft:
-                    guard: is_granted('edit, 'article')
+                    guard: is_granted('edit', subject)
                     from: published
                     to: draft
 

+ 31 - 0
migrations/Version20241023225952.php

@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace DoctrineMigrations;
+
+use Doctrine\DBAL\Schema\Schema;
+use Doctrine\Migrations\AbstractMigration;
+
+/**
+ * Auto-generated Migration: Please modify to your needs!
+ */
+final class Version20241023225952 extends AbstractMigration
+{
+    public function getDescription(): string
+    {
+        return '';
+    }
+
+    public function up(Schema $schema): void
+    {
+        // this up() migration is auto-generated, please modify it to your needs
+        $this->addSql('ALTER TABLE article CHANGE state state VARCHAR(64) NOT NULL');
+    }
+
+    public function down(Schema $schema): void
+    {
+        // this down() migration is auto-generated, please modify it to your needs
+        $this->addSql('ALTER TABLE article CHANGE state state VARCHAR(64) DEFAULT \'draft\' NOT NULL');
+    }
+}

+ 46 - 4
src/Controller/ArticleController.php

@@ -7,10 +7,12 @@ use App\Form\ArticleType;
 use App\Repository\ArticleRepository;
 use Doctrine\ORM\EntityManagerInterface;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\DependencyInjection\Attribute\Target;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\Routing\Attribute\Route;
 use Symfony\Component\Security\Http\Attribute\IsGranted;
+use Symfony\Component\Workflow\WorkflowInterface;
 
 #[Route('/article')]
 #[IsGranted('ROLE_AUTHOR')]
@@ -25,13 +27,33 @@ final class ArticleController extends AbstractController
     }
 
     #[Route('/new', name: 'app_article_new', methods: ['GET', 'POST'])]
-    public function new(Request $request, EntityManagerInterface $entityManager): Response
+    public function new(
+        Request $request,
+        EntityManagerInterface $entityManager,
+        #[Target('blog_publishing')]
+        WorkflowInterface $workflow
+    ): Response
     {
         $article = new Article($this->getUser());
-        $form = $this->createForm(ArticleType::class, $article);
+        $workflow->getMarking($article);
+
+        $transitions = $workflow->getEnabledTransitions($article);
+        $transitionsChoice = [];
+        foreach($transitions as $transition) {
+            $transitionsChoice[$transition->getName()] = $transition->getName();
+        }
+        
+        $form = $this->createForm(ArticleType::class, $article, [
+            'transitions' => $transitionsChoice
+        ]);
         $form->handleRequest($request);
 
         if ($form->isSubmitted() && $form->isValid()) {
+            $publicationChoice = $form->get('publicationChoice')->getData();
+            if ($publicationChoice !== "") {
+                $workflow->apply($article, $publicationChoice);
+            }
+
             $entityManager->persist($article);
             $entityManager->flush();
 
@@ -55,12 +77,32 @@ final class ArticleController extends AbstractController
 
     #[Route('/{id}/edit', name: 'app_article_edit', methods: ['GET', 'POST'])]
     #[IsGranted('edit', 'article')]
-    public function edit(Request $request, Article $article, EntityManagerInterface $entityManager): Response
+    public function edit(
+        Request $request,
+        Article $article,
+        EntityManagerInterface $entityManager,
+        #[Target('blog_publishing')]
+        WorkflowInterface $workflow
+    ): Response
     {
-        $form = $this->createForm(ArticleType::class, $article);
+        $transitions = $workflow->getEnabledTransitions($article);
+        $transitionsChoice = [];
+        foreach($transitions as $transition) {
+            $transitionsChoice[$transition->getName()] = $transition->getName();
+        }
+        
+        $form = $this->createForm(ArticleType::class, $article, [
+            'transitions' => $transitionsChoice
+        ]);
+
         $form->handleRequest($request);
 
         if ($form->isSubmitted() && $form->isValid()) {
+            $publicationChoice = $form->get('publicationChoice')->getData();
+            if ($publicationChoice !== "") {
+                $workflow->apply($article, $publicationChoice);
+            }
+
             $entityManager->flush();
 
             return $this->redirectToRoute('app_article_index', [], Response::HTTP_SEE_OTHER);

+ 1 - 1
src/Controller/IndexController.php

@@ -12,7 +12,7 @@ class IndexController extends AbstractController
     #[Route('/', name: 'app_index')]
     public function index(ArticleRepository $articleRepository): Response
     {
-        $articles = $articleRepository->findAll();
+        $articles = $articleRepository->findBy(['state' => 'published']);
 
         return $this->render('index/index.html.twig', [
             'articles'  => $articles

+ 1 - 1
src/Entity/Article.php

@@ -94,7 +94,7 @@ class Article
         return $this->state;
     }
 
-    public function setState(string $state): static
+    public function setState(string $state, array $context = []): static
     {
         $this->state = $state;
 

+ 11 - 0
src/Form/ArticleType.php

@@ -6,9 +6,12 @@ use App\Entity\Article;
 use App\Entity\User;
 use FOS\CKEditorBundle\Form\Type\CKEditorType;
 use Symfony\Bridge\Doctrine\Form\Type\EntityType;
+use Symfony\Component\DependencyInjection\Attribute\Target;
 use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
 use Symfony\Component\Form\FormBuilderInterface;
 use Symfony\Component\OptionsResolver\OptionsResolver;
+use Symfony\Component\Workflow\WorkflowInterface;
 
 class ArticleType extends AbstractType
 {
@@ -24,6 +27,12 @@ class ArticleType extends AbstractType
                 'class' => User::class,
                 'choice_label' => 'userIdentifier',
             ])
+            ->add('publicationChoice', ChoiceType::class, [
+                'mapped' => false,
+                'choices' => $options['transitions'],
+                'required' => false,
+                'choice_value'  => null
+            ])
         ;
     }
 
@@ -32,5 +41,7 @@ class ArticleType extends AbstractType
         $resolver->setDefaults([
             'data_class' => Article::class,
         ]);
+
+        $resolver->setRequired('transitions');
     }
 }

+ 12 - 1
src/Security/Voter/ArticleVoter.php

@@ -12,6 +12,7 @@ final class ArticleVoter extends Voter
 {
     public const EDIT = 'edit';
     public const VIEW = 'view';
+    public const PUBLISH = 'publish';
 
     public function __construct(
         private readonly Security $security
@@ -24,7 +25,7 @@ final class ArticleVoter extends Voter
     {
         // replace with your own logic
         // https://symfony.com/doc/current/security/voters.html
-        return in_array($attribute, [self::EDIT, self::VIEW])
+        return in_array($attribute, [self::EDIT, self::VIEW, self::PUBLISH])
             && $subject instanceof \App\Entity\Article;
     }
 
@@ -42,6 +43,7 @@ final class ArticleVoter extends Voter
         return match($attribute) {
             self::VIEW => $this->canView($article, $user),
             self::EDIT => $this->canEdit($article, $user),
+            self::PUBLISH => $this->canPublish($article, $user),
             default => throw new \LogicException('This code should not be reached!')
         };
     }
@@ -55,4 +57,13 @@ final class ArticleVoter extends Voter
     {
         return $user === $article->getAuthor() || $this->security->isGranted('ROLE_ADMIN');
     }
+
+    private function canPublish(Article $article, User $user) :bool
+    {
+        if ($this->canEdit($article, $user) && $article->getState() !== 'reviewed') {
+            return true;
+        }
+
+        return $this->security->isGranted('ROLE_MODERATOR');
+    }
 }

+ 2 - 2
templates/article/_delete_form.html.twig

@@ -1,4 +1,4 @@
-<form method="post" action="{{ path('app_article_delete', {'id': article.id}) }}" onsubmit="return confirm('Are you sure you want to delete this item?');">
+<form method="post" action="{{ path('app_article_delete', {'id': article.id}) }}" onsubmit="return confirm('Êtes-vous certain de vouloir supprimer cet article ?');">
     <input type="hidden" name="_token" value="{{ csrf_token('delete' ~ article.id) }}">
-    <button class="btn">Delete</button>
+    <button class="btn">Supprimer</button>
 </form>

+ 5 - 1
templates/article/_form.html.twig

@@ -1,4 +1,8 @@
+<p>
+    {{ article.state | trans }}
+</p>
+
 {{ form_start(form) }}
     {{ form_widget(form) }}
-    <button class="btn">{{ button_label|default('Save') }}</button>
+    <button class="btn">{{ button_label|default('Sauvegarder') }}</button>
 {{ form_end(form) }}

+ 3 - 3
templates/article/edit.html.twig

@@ -1,13 +1,13 @@
 {% extends 'base.html.twig' %}
 
-{% block title %}Edit Article{% endblock %}
+{% block title %}Editer un article{% endblock %}
 
 {% block body %}
-    <h1>Edit Article</h1>
+    <h1>Editer un article</h1>
 
     {{ include('article/_form.html.twig', {'button_label': 'Update'}) }}
 
-    <a href="{{ path('app_article_index') }}">back to list</a>
+    <a href="{{ path('app_article_index') }}">Retour à la liste</a>
 
     {{ include('article/_delete_form.html.twig') }}
 {% endblock %}

+ 2 - 0
templates/article/index.html.twig

@@ -11,6 +11,7 @@
                 <th>Id</th>
                 <th>Title</th>
                 <th>PublicationDate</th>
+                <th>Etat</th>
                 <th>actions</th>
             </tr>
         </thead>
@@ -21,6 +22,7 @@
                 <td>{{ article.id }}</td>
                 <td>{{ article.title }}</td>
                 <td>{{ article.publicationDate ? article.publicationDate|date('Y-m-d H:i:s') : '' }}</td>
+                <td>{{ article.state | trans }}</td>
                 <td>
                     {% if is_granted('view', article) %}
                     <a href="{{ path('app_article_show', {'id': article.id}) }}">show</a>

+ 3 - 3
templates/article/new.html.twig

@@ -1,11 +1,11 @@
 {% extends 'base.html.twig' %}
 
-{% block title %}New Article{% endblock %}
+{% block title %}Nouvel article{% endblock %}
 
 {% block body %}
-    <h1>Create new Article</h1>
+    <h1>Créer un nouvel article</h1>
 
     {{ include('article/_form.html.twig') }}
 
-    <a href="{{ path('app_article_index') }}">back to list</a>
+    <a href="{{ path('app_article_index') }}">Retour à la liste</a>
 {% endblock %}

+ 4 - 0
templates/article/show.html.twig

@@ -19,6 +19,10 @@
                 <th>PublicationDate</th>
                 <td>{{ article.publicationDate ? article.publicationDate|date('Y-m-d H:i:s') : '' }}</td>
             </tr>
+            <tr>
+                <th>Etat</th>
+                <td>{{ article.state | trans }}</td>
+            </tr>
             <tr>
                 <th>Content</th>
                 <td>{{ article.content | raw }}</td>

+ 9 - 0
translations/messages.fr.yaml

@@ -0,0 +1,9 @@
+draft: Brouillon
+published: Publié
+reviewed: En revue
+rejected: Rejeté
+publish: Publier
+to_draft: Retour au brouillon
+reject: Rejeter
+to_review: Faire relire
+Publication choice : Choix de publication

BIN
workflow.png