12/03/2023

3. Participation aux événements

Lu 465 fois Licence Creative Commons

On veut que des utilisateurs puissent indiquer leur participation aux événements

Relation

Ajouter une relation ManyToMany entre les User et Event:

php bin/console make:entity                                                                                                                                 
[output]                                                                                                                                                              
[output] Class name of the entity to create or update (e.g. AgreeablePizza):                                                                                          
 > User                                                                                                                                                       
[output]                                                                                                                                                              
[output] Your entity already exists! So let's add some new fields!                                                                                                    
[output]                                                                                                                                                              
[output] New property name (press <return> to stop adding fields):                                                                                                    
 > events                                                                                                                                                     
[output]
[output] Field type (enter ? to see all types) [string]:
 > relation
[output]
[output] What class should this entity be related to?:
 > Event
[output]
[output]What type of relationship is this?
[output] ------------ ------------------------------------------------------------------ 
[output]  Type         Description                                                       
[output] ------------ ------------------------------------------------------------------ 
[output]  ManyToOne    Each User relates to (has) one Event.                              
[output]               Each Event can relate to (can have) many User objects.             
[output]                                                                                  
[output]  OneToMany    Each User can relate to (can have) many Event objects.             
[output]               Each Event relates to (has) one User.                              
[output]                                                                                  
[output]  ManyToMany   Each User can relate to (can have) many Event objects.             
[output]               Each Event can also relate to (can also have) many User objects.   
[output]                                                                                  
[output]  OneToOne     Each User relates to (has) exactly one Event.                      
[output]               Each Event also relates to (has) exactly one User.                 
[output] ------------ ------------------------------------------------------------------ 
[output]
[output] Relation type? [ManyToOne, OneToMany, ManyToMany, OneToOne]:
  > ManyToMany
[output]
[output] Do you want to add a new property to Event so that you can access/update User objects from it - e.g. $event->getUsers()? (yes/no) [yes]:
 > 
[output]
[output] A new property will also be added to the Event class so that you can access the related User objects from it.
[output]
[output] New field name inside Event [users]:
 > participants

Générer et exécuter une migration:

php bin/console make:migration
php bin/console doctrine:migrations:migrate

Mettre à jour les fixtures pour les utilisateurs afin qu'ils participent à des événements:

App\Entity\User:
    user_{1..100}:
        # ...
        events: '<numberBetween(0, 10)>x @event_*'

Note: on utilise la notation @reference_* pour récupérer une référence aléatoire aux entités Event, et le préfixe NxN est dynamique et permet d'associer entre 0 et 10 références à des entités Event.

Puis on recharge les fixtures:

php bin/console hautelook:fixtures:load

Liste d'événements

Pour pouvoir afficher les événements passés et futurs de l'utilisateur sur sa page de profil, ajouter deux méthodes dans l'entité User comme nous l'avons fait pour les lieux:

/**
 * Liste des prochains événements
 * @return Collection<int, Event>
 */
public function getUpcomingEvents(): Collection
{
	return $this->events
		->filter(fn (Event $event) => $event->getStartAt() > new \DateTime())
		->matching(Criteria::create()->orderBy(['startAt' => 'ASC']))
	;
}

/**
 * Liste des événements passés
 * @return Collection<int, Event>
 */
public function getPastEvents(): Collection
{
	return $this->events
		->filter(fn (Event $event) => $event->getStartAt() < new \DateTime())
		->matching(Criteria::create()->orderBy(['startAt' => 'DESC']))
	;
}

Sur la page de profil, ajouter la liste des événements auxquels participe l'utilisateur avec le même système d'onglet que pour la page des lieux:

{% block content %}
    <div class="section">
        <h1 class="title">Profil de {{ app.user.pseudo }}:</h1>

        <div class="content">
            <p>
                <strong>Email</strong>: {{ app.user.email }}<br>
                <strong>Pseudo</strong>: {{ app.user.pseudo }}<br>
            </p>
            <h3>Événements:</h3>
        </div>

        <div class="tabs is-fullwidth">
            <ul>
                <li class="{{ app.request.query.get('tab', 'upcoming') == 'upcoming' ? 'is-active' : '' }}">
                    <a href="{{ path('user_profile', {tab: 'upcoming'}) }}">
                        <span class="icon is-small"><i class="fa-regular fa-star"></i></span>
                        <span>À venir ({{ app.user.upcomingEvents|length }})</span>
                    </a>
                </li>
                <li class="{{ app.request.query.get('tab') == 'past' ? 'is-active' : '' }}">
                    <a href="{{ path('user_profile', {tab: 'past'}) }}">
                        <span class="icon is-small"><i class="fa-solid fa-clock-rotate-left"></i></span>
                        <span>Passés ({{ app.user.pastEvents|length }})</span>
                    </a>
                </li>
            </ul>
        </div>

        <div class="{{ app.request.query.get('tab', 'upcoming') == 'upcoming' ? '' : 'is-hidden' }}">
            {% for event in app.user.upcomingEvents %}
                {% include '_includes/event_card.html.twig' with {event} %}
            {% else %}
                <div class="card">
                    <div class="card-content">
                        <div class="content">
                            <p class="title is-4">
                                <span class="icon"><i class="fa-regular fa-face-frown"></i></span>
                                <span>Oups !</span>
                            </p>
                            <p class="subtitle is-6">
                                Vous n'avez aucun événement prévu prochainement.
                            </p>
                        </div>
                    </div>
                </div>
            {% endfor %}
        </div>

        <div class="{{ app.request.query.get('tab') == 'past' ? '' : 'is-hidden' }}">
            {% for event in app.user.pastEvents %}
                {% include '_includes/event_card.html.twig' with {event} %}
            {% else %}
                <div class="card">
                    <div class="card-content">
                        <div class="content">
                            <p class="title is-4">
                                <span class="icon"><i class="fa-regular fa-face-frown"></i></span>
                                <span>Oups !</span>
                            </p>
                            <p class="subtitle is-6">
                                Vous n'avez aucun événement passé pour le moment.
                            </p>
                        </div>
                    </div>
                </div>
            {% endfor %}
        </div>
    </div>
{% endblock %}

Participation aux événements

Voter

Pour pouvoir participer à un événement ou annuler sa participation, il faudra que l'utilisateur courant soit connecté. Mais il faudra également vérifier que l'événement n'est pas déjà passé !
Dans ce cas, une simple vérification de rôle n'est pas suffisant, il faut vérifier au cas par cas. Plutôt que de contourner le système de sécurité de Symfony avec une "solution maison", on peut utiliser des voters.

Un voter est un service chargé de vérifier un droit d'accès. Ces droits sont représentés sous la forme d'attributs en chaîne de caractères, avec le nom de notre choix. Nous pourrons ensuite utiliser les fonctions de vérification de droits d'accès comme pour les rôles. En réalité, la vérification des rôles est effectuée par un voter !

Créer un voter pour la gestion de participation:

php bin/console make:voter
[output]
[output] The name of the security voter class (e.g. BlogPostVoter):
 > EventParticipationVoter
[output]
[output] created: src/Security/Voter/EventParticipationVoter.php

La méthode supports() doit indiquer si elle est en mesure de gérer le droit d'accès, en fonction de l'attribut testé et du sujet du droit d'accès.
Le voter servira a vérifier la participation à un événement et vérifier si on peut changer sa participation:

class EventParticipationVoter extends Voter
{
    public const EDIT = 'EVENT_PARTICIPATION_EDIT';
    public const IS_ACTIVE = 'EVENT_PARTICIPATION_IS_ACTIVE';

    protected function supports(string $attribute, mixed $subject): bool
    {
        return in_array($attribute, [self::EDIT, self::IS_ACTIVE]) && $subject instanceof Event;
    }
	
	// ...
}

Si supports() retourne true, la méthode voteOnAttribute() est exécutée pour vérifier le droit d'accès:

/**
 * @param Event $subject
 */
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
	$user = $token->getUser();
	// Utilisateur non connecté
	if (!$user instanceof User) {
		return false;
	}

	return match ($attribute) {
		// L'événement ne doit pas avoir commencé pour modifier sa participation
		self::EDIT => null !== $subject->getStartAt() && $subject->getStartAt() > new \DateTime(),
		self::IS_ACTIVE => $user->getEvents()->contains($subject),
		default => false,
	};
}

Route

Nous allons créer une route dans EventController pour indiquer ou annuler une participation à un événement. Le chemin comporte des paramètres pour identifier l'événement concerné ainsi qu'un jeton CSRF pour protéger l'action:

use Symfony\Component\Security\Core\Security;

// ...

#[Route('/event/{id}/participate/{token}', name: 'participation_edit')]
#[IsGranted('EVENT_PARTICIPATION_EDIT', subject: 'event')]
public function eventParticipation(
	Event $event,
	string $token,
	Request $request,
	EntityManagerInterface $entityManager,
	Security $security,
): Response {
	// Récupération de l'utilisateur courant
	$user = $security->getUser();

	// Vérification du jeton CSRF
	if ($user instanceof User && $this->isCsrfTokenValid('event-participation-edit', $token)) {
		// Ajoute/supprime la participation selon la présence du paramètre "cancel" dans l'URL
		if ($request->query->has('cancel')) {
			$user->removeEvent($event);
		} else {
			$user->addEvent($event);
		}

		// Sauvegarde en base de données
		$entityManager->flush();
	}

	// Redirection vers la page précédente
	$previousPage = $request->headers->get('referer');
	if (null !== $previousPage) {
		return $this->redirect($previousPage);
	}

	// ... ou sur la page de l'événement par défaut
	return $this->redirectToRoute('event_page', [
		'id' => $event->getId(),
	]);
}

Puisqu'un voter gère le cas par cas sur un sujet, il est important de ne pas oublier l'argument subject dans l'attribut.

Template

Créer un template /templates/_includes/event_participation.html.twig pour afficher les liens de participation. Encore une fois, la fonction is_granted() nécessite un second argument sur lequel accorder ou refuser le droit d'accès. On génère le jeton CSRF avec la fonction Twig dédiée:

{% if is_granted('ROLE_USER') %}
    {% if is_granted('EVENT_PARTICIPATION_EDIT', event) %}
        {% if is_granted('EVENT_PARTICIPATION_IS_ACTIVE', event) %}
            <a href="{{ path('event_participation_edit', {id: event.id, token: csrf_token('event-participation-edit'), cancel: true}) }}" class="button is-danger is-outlined my-4">
                Je ne participe plus
            </a>
        {% else %}
            <a href="{{ path('event_participation_edit', {id: event.id, token: csrf_token('event-participation-edit')}) }}" class="button is-success is-outlined my-4">
                Je participe !
            </a>
        {% endif %}
    {% elseif is_granted('EVENT_PARTICIPATION_IS_ACTIVE', event) %}
        <div class="notification is-info is-light">
            Vous avez participé à l'événement.
        </div>
    {% endif %}
{% endif %}

Dans /templates/_includes/event_card.html.twig, ajouter une icone pour afficher le nombre de participants, puis inclure les liens de participation avant la description:

<div class="box columns my-6 p-0">
    {# ... #}
    <div class="column">
        <div class="content">
			{# ... #}
            <p class="is-size-6">
				{# ... #}
                <i class="fa-solid fa-clock"></i> {{ event.startAt|date('H\\hi') }} - {{ event.endAt|date('H\\hi') }}<br>
                <i class="fa-solid fa-users-line"></i> {{ event.participants|length }} participants
            </p>

            {% include '_includes/event_participation.html.twig' with {event} %}

            <p>{{ event.description }}</p>
        </div>
    </div>
</div>

On ajoute la même chose dans /templates/event/page.html.twig:

{% block content %}
    <div class="section">
        <div class="columns">
            {# ... #}
            <div class="column">
				{# ... #}
                <div class="columns">
                    <div class="column">
                        <div class="notification is-info is-light">
							{# ... #}
                            <i class="fa-solid fa-clock"></i> de {{ event.startAt|date('H\\hi') }} à {{ event.endAt|date('H\\hi') }}<br>
                            <i class="fa-solid fa-users-line"></i> {{ event.participants|length }} participants
                        </div>
                    </div>
					{# ... #}
                </div>

                {% include '_includes/event_participation.html.twig' with {event} %}

                <p>{{ event.description }}</p>
            </div>
        </div>
    </div>
{% endblock %}

À vous de jouer !
Connectez-vous avec un compte utilisateur et modifiez votre participations aux événements depuis différentes pages.